verikloak-audience 0.3.0 → 0.3.1

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: d97d79e3f6ccd8f2c920cc7dcb66612e9edbd577ba4be2f5948eddd16145dd9b
4
- data.tar.gz: 4517ee945923ec595fa7447f33dd0e9370cc72a2a561bc04d4f910efed018595
3
+ metadata.gz: 400925f130b177647a3d7007cf4e43b566451e5b131968fbca2946d8a2f47d06
4
+ data.tar.gz: 89fa1b482fe9cdaa9278f56380cc59bba52325dd7db6b0e9aeb473e658568eb8
5
5
  SHA512:
6
- metadata.gz: 83c14846297272179ccf6f4480dab677c82be3d21b32a6c374c3b40fc5be04dd0cf9c9c439457e9dd33d4b4a9fca393aca31b7104b4282479b872ad8987c8632
7
- data.tar.gz: e2e125eab982f983e6447166bcd6b9d9413f15573301335f15ef53ba0bafb6d07020848ec942e9b532db69b29ab3fc6280ae9fc6f74d12ff9786a39fc64dccde
6
+ metadata.gz: 0d39de8ac4bfb587b968e4017e36edc03fbe49c4a7495963671aacb32a5d65fa06b39957922344b1dd3336614bb20b5ad8eeef9c7248eb6ea9c82dd772323072
7
+ data.tar.gz: bc01de7533c0388fc4e9f8ed35747b9136c04f166cbcfe0dbc75ff2895703aed960ed0d3845beb0dd3ecc2a3bffae786609864a59ae335672f6754a216adda4d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.3.1] - 2026-01-01
11
+
12
+ ### Added
13
+ - **`:any_match` profile**: New audience validation profile that passes when at least one of the required audiences is present in the token.
14
+ - **`skip_paths` configuration**: Skip audience validation for specific paths (e.g., health checks). Supports exact matches, prefix matches, wildcard patterns, and Regexp.
15
+ - **Generator improvements**: Install generator now includes `Verikloak::Audience.configure` block with all four profiles documented.
16
+
17
+ ### Fixed
18
+ - **Regexp support in `skip_paths`**: Fixed `NoMethodError` when passing Regexp patterns.
19
+ - **Warning message accuracy**: Unconfigured warning now correctly states "ALL requests will be rejected with 403".
20
+ - Configuration sync now happens before middleware insertion.
21
+
22
+ ### Changed
23
+ - README now correctly describes that the Railtie automatically inserts the middleware.
24
+ - Removed manual `insert_middleware` call from generated initializer (Railtie handles this automatically).
25
+
10
26
  ## [0.3.0] - 2026-01-01
11
27
 
12
28
  ### Fixed
data/README.md CHANGED
@@ -22,6 +22,7 @@ For the full error behaviour (response shapes, exception classes, logging hints)
22
22
  |---------|---------|-----------------------------------------------------------|
23
23
  | `:strict_single` *(recommended)* | Requires `aud` to match `required_aud` exactly (order-insensitive, no extras). | APIs where audiences are cleanly separated. Logged suggestion when the observed `aud` already equals the configured list. |
24
24
  | `:allow_account` | Allows `account` in addition to required audiences (e.g. `["rails-api","account"]`). | SPA + API mixes where Keycloak always emits `account`. Suggested when strict mode fails and the log shows `profile=:allow_account`. |
25
+ | `:any_match` | Passes when at least one of the required audiences is present. | Shared APIs where multiple clients may have overlapping audiences. More permissive than `:strict_single`. |
25
26
  | `:resource_or_aud` | Passes when `resource_access[client].roles` is present; otherwise falls back to `:allow_account`. | Services relying on resource roles. Suggested when logs output `profile=:resource_or_aud`. |
26
27
 
27
28
  ## Installation
@@ -30,13 +31,14 @@ For the full error behaviour (response shapes, exception classes, logging hints)
30
31
  bundle add verikloak-audience
31
32
  ```
32
33
 
33
- In Rails applications, generate the initializer that automatically inserts the middleware:
34
+ In Rails applications, run the generator to create a configuration initializer:
34
35
 
35
36
  ```bash
36
37
  rails g verikloak:audience:install
37
38
  ```
38
39
 
39
- This creates `config/initializers/verikloak_audience.rb` that will insert the audience middleware after the core Verikloak middleware once it's available.
40
+ This creates `config/initializers/verikloak_audience.rb` with configuration options.
41
+ The middleware is automatically inserted by the Railtie after `Verikloak::Middleware`.
40
42
 
41
43
  ## Manual Rack / Rails setup
42
44
 
@@ -58,7 +60,7 @@ See [`examples/rack.ru`](examples/rack.ru) for a full Rack sample. In Rails, alw
58
60
 
59
61
  | Option | Type | Default | Description |
60
62
  |--------|------|---------|-------------|
61
- | `profile` | Symbol | `:strict_single` | Profile selector. Accepts `:strict_single`, `:allow_account`, or `:resource_or_aud`. |
63
+ | `profile` | Symbol | `:strict_single` | Profile selector. Accepts `:strict_single`, `:allow_account`, `:any_match`, or `:resource_or_aud`. |
62
64
  | `required_aud` | Array/String/Symbol | `[]` | Required audience values; coerced to an array internally. |
63
65
  | `resource_client` | String | `"rails-api"` | Keycloak client id used to look up `resource_access[client].roles`. |
64
66
  | `env_claims_key` | String | `"verikloak.user"` | Rack env key where verified claims are stored. |
@@ -1,11 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Insert the audience middleware after the core Verikloak middleware once it
4
- # has been loaded into the application.
3
+ # Verikloak Audience Configuration
5
4
  #
6
- # The Railtie's insert_middleware method includes internal guards to prevent
7
- # duplicate insertion when both the railtie initializer and this generated
8
- # initializer are loaded. This is safe to call multiple times.
9
- if defined?(Rails) && Rails.respond_to?(:application)
10
- Verikloak::Audience::Railtie.insert_middleware(Rails.application)
5
+ # Configure the audience validation profile and settings.
6
+ # This middleware provides stricter audience validation than core Verikloak.
7
+ #
8
+ # The middleware is automatically inserted after Verikloak::Middleware by the
9
+ # Railtie. No manual middleware insertion is required.
10
+ #
11
+ Verikloak::Audience.configure do |config|
12
+ # Audience validation profile
13
+ # :strict_single - Only exact single audience match (default, recommended)
14
+ # :allow_account - Allow "account" alongside your API audience
15
+ # :any_match - At least one required audience is present (more permissive)
16
+ # :resource_or_aud - Check resource_access roles first, fallback to :allow_account
17
+ # config.profile = :strict_single
18
+
19
+ # Required audience(s) - synced from verikloak-rails automatically
20
+ # config.required_aud = ['rails-api']
21
+
22
+ # Skip paths for audience validation (synced from verikloak-rails automatically)
23
+ # config.skip_paths = %w[/up /health]
11
24
  end
@@ -7,7 +7,7 @@ module Verikloak
7
7
  # This module provides predicate helpers used by the middleware to decide
8
8
  # whether a given set of claims satisfies the configured profile.
9
9
  module Checker
10
- VALID_PROFILES = %i[strict_single allow_account resource_or_aud].freeze
10
+ VALID_PROFILES = %i[strict_single allow_account any_match resource_or_aud].freeze
11
11
 
12
12
  module_function
13
13
 
@@ -33,6 +33,8 @@ module Verikloak
33
33
  strict_single?(claims, cfg.required_aud_list)
34
34
  when :allow_account
35
35
  allow_account?(claims, cfg.required_aud_list)
36
+ when :any_match
37
+ any_match?(claims, cfg.required_aud_list)
36
38
  when :resource_or_aud
37
39
  resource_or_aud?(claims, cfg.resource_client.to_s, cfg.required_aud_list)
38
40
  end
@@ -66,6 +68,20 @@ module Verikloak
66
68
  extras.empty? && (required - aud).empty?
67
69
  end
68
70
 
71
+ # Validate that at least one required audience is present in the token.
72
+ # More permissive than :strict_single; useful when multiple clients share audiences.
73
+ #
74
+ # @param claims [Hash]
75
+ # @param required [Array<String>]
76
+ # @return [Boolean]
77
+ def any_match?(claims, required)
78
+ aud = normalized_audiences(claims)
79
+ return false if required.empty?
80
+
81
+ # At least one of the required audiences must be present
82
+ aud.intersect?(required.map(&:to_s))
83
+ end
84
+
69
85
  # Permit when resource roles exist for the client; otherwise fallback to
70
86
  # {#allow_account?}.
71
87
  #
@@ -92,6 +108,7 @@ module Verikloak
92
108
  required = cfg.required_aud_list
93
109
  return :strict_single if strict_single?(claims, required)
94
110
  return :allow_account if allow_account?(claims, required)
111
+ return :any_match if any_match?(claims, required)
95
112
  return :resource_or_aud if resource_or_aud?(claims, cfg.resource_client.to_s, required)
96
113
 
97
114
  :strict_single
@@ -8,7 +8,7 @@ module Verikloak
8
8
  #
9
9
  # @!attribute [rw] profile
10
10
  # The enforcement profile to use.
11
- # @return [:strict_single, :allow_account, :resource_or_aud]
11
+ # @return [:strict_single, :allow_account, :any_match, :resource_or_aud]
12
12
  # @!attribute [rw] required_aud
13
13
  # Required audience(s). Can be a String/Symbol or an Array of them.
14
14
  # @return [Array<String,Symbol>, String, Symbol]
@@ -21,12 +21,16 @@ module Verikloak
21
21
  # @!attribute [rw] suggest_in_logs
22
22
  # Whether to log a suggestion when audience validation fails.
23
23
  # @return [Boolean]
24
+ # @!attribute [rw] skip_paths
25
+ # Paths to skip audience validation for (e.g., health checks).
26
+ # Synced from verikloak-rails when available.
27
+ # @return [Array<String, Regexp>]
24
28
  class Configuration
25
29
  DEFAULT_RESOURCE_CLIENT = 'rails-api'
26
30
  DEFAULT_ENV_CLAIMS_KEY = 'verikloak.user'
27
31
 
28
32
  attr_accessor :profile, :required_aud, :resource_client,
29
- :suggest_in_logs
33
+ :suggest_in_logs, :skip_paths
30
34
  attr_reader :env_claims_key
31
35
 
32
36
  # Create a configuration with safe defaults.
@@ -38,6 +42,7 @@ module Verikloak
38
42
  @resource_client = DEFAULT_RESOURCE_CLIENT
39
43
  self.env_claims_key = DEFAULT_ENV_CLAIMS_KEY
40
44
  @suggest_in_logs = true
45
+ @skip_paths = []
41
46
  end
42
47
 
43
48
  # Ensure `dup` produces an independent copy.
@@ -51,6 +56,7 @@ module Verikloak
51
56
  @resource_client = safe_dup(source.resource_client)
52
57
  self.env_claims_key = safe_dup(source.env_claims_key)
53
58
  @suggest_in_logs = source.suggest_in_logs
59
+ @skip_paths = source.skip_paths&.dup || []
54
60
  end
55
61
 
56
62
  # Coerce `required_aud` into an array of strings.
@@ -15,11 +15,12 @@ module Verikloak
15
15
  class Middleware
16
16
  # @param app [#call] next Rack application
17
17
  # @param opts [Hash] configuration overrides (see {Configuration})
18
- # @option opts [Symbol] :profile
18
+ # @option opts [Symbol] :profile (:strict_single, :allow_account, :any_match, :resource_or_aud)
19
19
  # @option opts [Array<String>,String,Symbol] :required_aud
20
20
  # @option opts [String] :resource_client
21
21
  # @option opts [String] :env_claims_key
22
22
  # @option opts [Boolean] :suggest_in_logs
23
+ # @option opts [Array<String, Regexp>] :skip_paths
23
24
  def initialize(app, **opts)
24
25
  @app = app
25
26
  @config = Verikloak::Audience.config.dup
@@ -32,6 +33,9 @@ module Verikloak
32
33
  # @param env [Hash] Rack environment
33
34
  # @return [Array(Integer, Hash, #each)] Rack response triple
34
35
  def call(env)
36
+ # Skip audience validation for configured paths (e.g., health checks)
37
+ return @app.call(env) if skip_path?(env)
38
+
35
39
  env_key = @config.env_claims_key
36
40
  claims = env[env_key] || env[env_key&.to_sym] || {}
37
41
  return @app.call(env) if Checker.ok?(claims, @config)
@@ -51,6 +55,36 @@ module Verikloak
51
55
 
52
56
  private
53
57
 
58
+ # Check if the current request path should skip audience validation.
59
+ #
60
+ # @param env [Hash] Rack environment
61
+ # @return [Boolean]
62
+ def skip_path?(env)
63
+ paths = @config.skip_paths
64
+ return false if paths.nil? || paths.empty?
65
+
66
+ request_path = env['PATH_INFO'] || env['REQUEST_PATH'] || ''
67
+ paths.any? { |pattern| path_matches?(request_path, pattern) }
68
+ end
69
+
70
+ # Determine if a request path matches the given pattern.
71
+ # Supports exact matches and prefix matches (pattern ending with *).
72
+ #
73
+ # @param request_path [String]
74
+ # @param pattern [String, Regexp]
75
+ # @return [Boolean]
76
+ def path_matches?(request_path, pattern)
77
+ return false if pattern.nil?
78
+ return request_path.match?(pattern) if pattern.is_a?(Regexp)
79
+ return false if pattern.empty?
80
+
81
+ if pattern.end_with?('*')
82
+ request_path.start_with?(pattern.chomp('*'))
83
+ else
84
+ request_path == pattern || request_path.start_with?("#{pattern}/")
85
+ end
86
+ end
87
+
54
88
  # Apply provided options to the configuration instance.
55
89
  #
56
90
  # @param opts [Hash] raw overrides provided to the middleware
@@ -70,14 +104,24 @@ module Verikloak
70
104
 
71
105
  # Determine whether configuration validation should run. This allows
72
106
  # Rails generators to boot without a fully-populated configuration since
73
- # the install task is responsible for creating it.
107
+ # the install task is responsible for creating it. Also skips validation
108
+ # when configuration is not explicitly set up.
74
109
  #
75
110
  # @return [Boolean]
76
111
  def skip_validation?
77
- return false unless defined?(::Verikloak::Audience::Railtie)
78
- return false unless ::Verikloak::Audience::Railtie.respond_to?(:skip_configuration_validation?)
112
+ # Skip if Railtie indicates generator mode
113
+ if defined?(::Verikloak::Audience::Railtie)
114
+ if ::Verikloak::Audience::Railtie.respond_to?(:skip_configuration_validation?) && ::Verikloak::Audience::Railtie.skip_configuration_validation?
115
+ return true
116
+ end
117
+
118
+ # Skip if configuration is incomplete (no audiences configured)
119
+ if ::Verikloak::Audience::Railtie.respond_to?(:skip_unconfigured_validation?) && ::Verikloak::Audience::Railtie.skip_unconfigured_validation?
120
+ return true
121
+ end
122
+ end
79
123
 
80
- ::Verikloak::Audience::Railtie.skip_configuration_validation?
124
+ false
81
125
  end
82
126
 
83
127
  # Emit a warning for failed audience checks using request-scoped loggers
@@ -4,6 +4,177 @@ require 'rails/railtie'
4
4
 
5
5
  module Verikloak
6
6
  module Audience
7
+ # Warning messages for Railtie.
8
+ module RailtieWarnings
9
+ WARNING_MESSAGE = <<~MSG
10
+ [verikloak-audience] Skipping automatic middleware insertion because ::Verikloak::Middleware
11
+ is not present in the Rails middleware stack.
12
+
13
+ To enable verikloak-audience, first ensure that the core Verikloak middleware (`Verikloak::Middleware`)
14
+ is added to your Rails middleware stack. Once the core middleware is present, you can run
15
+ `rails g verikloak:audience:install` to generate the initializer for the audience middleware,
16
+ or manually add:
17
+
18
+ config.middleware.insert_after Verikloak::Middleware, Verikloak::Audience::Middleware
19
+
20
+ This warning will disappear once the core middleware is properly configured and the audience
21
+ middleware is inserted.
22
+ MSG
23
+
24
+ CORE_NOT_CONFIGURED_WARNING = <<~MSG
25
+ [verikloak-audience] Skipping automatic middleware insertion because verikloak-rails
26
+ is not fully configured (discovery_url is not set).
27
+
28
+ The audience middleware requires the core Verikloak middleware to be present in the stack.
29
+ Please configure verikloak-rails first by setting the discovery_url in your initializer.
30
+ MSG
31
+
32
+ UNCONFIGURED_WARNING = <<~MSG
33
+ [verikloak-audience] Skipping configuration validation because required_aud is not configured.
34
+
35
+ To use verikloak-audience, you must configure at least one required audience.
36
+ Run `rails g verikloak:audience:install` to generate the initializer, or add:
37
+
38
+ Verikloak::Audience.configure do |config|
39
+ config.required_aud = ['your-audience']
40
+ end
41
+
42
+ WARNING: Without this configuration, ALL requests will be rejected with 403.
43
+ MSG
44
+
45
+ def log_warning(message)
46
+ logger = (::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger))
47
+ logger ? logger.warn(message) : Kernel.warn(message)
48
+ end
49
+
50
+ def warn_core_not_configured = log_warning(CORE_NOT_CONFIGURED_WARNING)
51
+ def warn_missing_core_middleware = log_warning(WARNING_MESSAGE)
52
+ def warn_unconfigured = log_warning(UNCONFIGURED_WARNING)
53
+ end
54
+
55
+ # Helper methods for Railtie configuration sync.
56
+ module RailtieHelpers
57
+ include RailtieWarnings
58
+
59
+ def apply_verikloak_rails_configuration
60
+ rails_config = verikloak_rails_config
61
+ return unless rails_config
62
+
63
+ Verikloak::Audience.configure do |cfg|
64
+ sync_env_claims_key(cfg, rails_config)
65
+ sync_required_aud(cfg, rails_config)
66
+ sync_resource_client(cfg, rails_config)
67
+ sync_skip_paths(cfg, rails_config)
68
+ end
69
+ end
70
+
71
+ def discovery_url_configured?(value)
72
+ return false unless value
73
+ return !value.blank? if value.respond_to?(:blank?)
74
+ return !value.empty? if value.respond_to?(:empty?)
75
+
76
+ true
77
+ end
78
+
79
+ def middleware_not_found_error?(error)
80
+ return true if error.class.name.to_s.include?('MiddlewareNotFound')
81
+
82
+ message = error.message.to_s
83
+ message.include?('No such middleware') ||
84
+ message.include?('does not exist') ||
85
+ message.match?(/middleware.*not found/i)
86
+ end
87
+
88
+ def verikloak_install_generator?(command)
89
+ return false unless command.is_a?(String)
90
+
91
+ command.start_with?('verikloak:') && command.end_with?(':install')
92
+ end
93
+
94
+ def first_cli_tokens
95
+ tokens = []
96
+ ARGV.each do |arg|
97
+ next if arg.start_with?('-') || arg == 'rails'
98
+
99
+ tokens << arg
100
+ break if tokens.size >= 2
101
+ end
102
+ tokens
103
+ end
104
+
105
+ private
106
+
107
+ def verikloak_rails_config
108
+ return unless defined?(::Verikloak::Rails)
109
+ return unless ::Verikloak::Rails.respond_to?(:config)
110
+
111
+ ::Verikloak::Rails.config
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ def sync_env_claims_key(cfg, rails_config)
117
+ return unless rails_config.respond_to?(:user_env_key)
118
+
119
+ user_key = rails_config.user_env_key
120
+ return if blank?(user_key)
121
+
122
+ current = cfg.env_claims_key
123
+ return unless current.nil? || current == Verikloak::Audience::Configuration::DEFAULT_ENV_CLAIMS_KEY
124
+
125
+ cfg.env_claims_key = user_key
126
+ end
127
+
128
+ def sync_required_aud(cfg, rails_config)
129
+ return unless cfg_required_aud_blank?(cfg)
130
+ return unless rails_config.respond_to?(:audience)
131
+
132
+ audiences = normalized_audiences(rails_config.audience)
133
+ return if audiences.empty?
134
+
135
+ cfg.required_aud = audiences.size == 1 ? audiences.first : audiences
136
+ end
137
+
138
+ def sync_resource_client(cfg, rails_config)
139
+ return unless rails_config.respond_to?(:audience)
140
+
141
+ audiences = normalized_audiences(rails_config.audience)
142
+ return unless audiences.size == 1
143
+
144
+ current_client = cfg.resource_client
145
+ unless blank?(current_client) || current_client == Verikloak::Audience::Configuration::DEFAULT_RESOURCE_CLIENT
146
+ return
147
+ end
148
+
149
+ cfg.resource_client = audiences.first
150
+ end
151
+
152
+ def sync_skip_paths(cfg, rails_config)
153
+ return unless rails_config.respond_to?(:skip_paths)
154
+
155
+ paths = rails_config.skip_paths
156
+ return if paths.nil?
157
+ return unless cfg.skip_paths.nil? || cfg.skip_paths.empty?
158
+
159
+ cfg.skip_paths = Array(paths).compact
160
+ end
161
+
162
+ def cfg_required_aud_blank?(cfg) = value_blank?(cfg.required_aud)
163
+
164
+ def value_blank?(value)
165
+ return true if value.nil?
166
+ return true if value.respond_to?(:empty?) && value.empty?
167
+
168
+ value.to_s.empty?
169
+ end
170
+
171
+ alias blank? value_blank?
172
+
173
+ def normalized_audiences(source)
174
+ Array(source).compact.map(&:to_s).reject(&:empty?)
175
+ end
176
+ end
177
+
7
178
  # Rails integration for verikloak-audience.
8
179
  #
9
180
  # This Railtie automatically inserts {Verikloak::Audience::Middleware}
@@ -24,305 +195,96 @@ module Verikloak
24
195
  # env_claims_key: 'verikloak.user',
25
196
  # suggest_in_logs: true
26
197
  class Railtie < ::Rails::Railtie
27
- # Adds the audience middleware after the core Verikloak middleware
28
- # when available.
29
- #
30
- # @param app [Rails::Application] the Rails application instance
198
+ class << self
199
+ attr_accessor :middleware_insertion_attempted, :unconfigured_warning_emitted
200
+
201
+ include RailtieHelpers
202
+ end
203
+
204
+ @middleware_insertion_attempted = false
205
+ @unconfigured_warning_emitted = false
206
+
31
207
  initializer 'verikloak_audience.middleware',
32
208
  after: 'verikloak.configure',
33
209
  before: :build_middleware_stack do |app|
34
- # Insert automatically after core verikloak if present
210
+ self.class.apply_verikloak_rails_configuration
35
211
  self.class.insert_middleware(app)
36
212
  end
37
213
 
38
214
  initializer 'verikloak_audience.configuration' do
39
215
  config.after_initialize do
40
- self.class.apply_verikloak_rails_configuration
41
216
  next if Verikloak::Audience::Railtie.skip_configuration_validation?
217
+ next if Verikloak::Audience::Railtie.skip_unconfigured_validation?
42
218
 
43
219
  Verikloak::Audience.config.validate!
44
220
  end
45
221
  end
46
222
 
47
- # Tracks whether middleware insertion has been attempted to prevent
48
- # duplicate insertions when both railtie and generator initializer run.
49
- # In Rails 8.x+, middleware operations may be queued and `include?` may
50
- # not reflect pending insertions, so we use this flag as an additional
51
- # safeguard.
52
- @middleware_insertion_attempted = false
53
-
54
- class << self
55
- attr_accessor :middleware_insertion_attempted
56
- end
223
+ COMMANDS_SKIPPING_VALIDATION = %w[generate g destroy d].freeze
57
224
 
58
- # Performs the insertion into the middleware stack when the core
59
- # Verikloak middleware is available and already present. Extracted for
60
- # testability without requiring a full Rails boot process.
61
- #
62
- # Insert the audience middleware after the base Verikloak middleware when
63
- # both are available on the stack.
64
- #
65
- # In Rails 8.x+, middleware stack operations may be queued rather than
66
- # immediately applied, so `include?` checks may return false even when
67
- # the middleware will be inserted. We use `insert_after` with exception
68
- # handling to gracefully handle this case.
69
- #
70
- # @param app [#middleware] An object exposing a Rack middleware stack via `#middleware`.
71
- # @return [void]
72
225
  def self.insert_middleware(app)
73
226
  return unless defined?(::Verikloak::Middleware)
74
227
 
75
- # Skip if we have already attempted insertion (handles Rails 8+ queued operations)
228
+ if defined?(::Verikloak::Rails) && ::Verikloak::Rails.respond_to?(:config)
229
+ discovery_url = ::Verikloak::Rails.config.discovery_url
230
+ unless discovery_url_configured?(discovery_url)
231
+ warn_core_not_configured
232
+ return
233
+ end
234
+ end
235
+
76
236
  return if middleware_insertion_attempted
77
237
 
78
- # Use app.config.middleware for queued operations in Rails 8.x+
79
- # This ensures the insert_after operation is queued and applied during
80
- # build_middleware_stack, maintaining proper ordering with verikloak-rails
81
238
  middleware_stack = app.respond_to?(:config) ? app.config.middleware : app.middleware
82
239
 
83
- # Skip if already present (avoid duplicate insertion)
84
- if middleware_stack.respond_to?(:include?) &&
85
- middleware_stack.include?(::Verikloak::Audience::Middleware)
240
+ if middleware_stack.respond_to?(:include?) && middleware_stack.include?(::Verikloak::Audience::Middleware)
86
241
  return
87
242
  end
88
243
 
89
- # Mark as attempted before insertion to prevent concurrent/subsequent calls
90
244
  self.middleware_insertion_attempted = true
91
245
 
92
- # Attempt to insert after the core Verikloak middleware.
93
- # In Rails 8.x+, the middleware may be queued but not yet visible via include?,
94
- # so we try the insertion and handle any exceptions gracefully.
95
246
  begin
96
247
  middleware_stack.insert_after ::Verikloak::Middleware, ::Verikloak::Audience::Middleware
97
248
  rescue StandardError => e
98
- # Handle middleware not found errors (varies by Rails version):
99
- # - Rails 8+: RuntimeError with "No such middleware" message
100
- # - Earlier: ActionDispatch::MiddlewareStack::MiddlewareNotFound
101
249
  raise unless middleware_not_found_error?(e)
102
250
 
103
251
  warn_missing_core_middleware
104
252
  end
105
253
  end
106
254
 
107
- # Determines if the given exception indicates a middleware not found error.
108
- # This handles variations across Rails versions:
109
- # - Rails 8+: RuntimeError with "No such middleware" message
110
- # - Rails 7 and earlier: ActionDispatch::MiddlewareStack::MiddlewareNotFound
111
- #
112
- # @param error [StandardError] the exception to check
113
- # @return [Boolean] true if the error indicates missing middleware
114
- def self.middleware_not_found_error?(error)
115
- # Check exception class name (works for Rails 7's MiddlewareNotFound)
116
- return true if error.class.name.to_s.include?('MiddlewareNotFound')
117
-
118
- # Check message patterns for Rails 8+ RuntimeError
119
- message = error.message.to_s
120
- message.include?('No such middleware') ||
121
- message.include?('does not exist') ||
122
- message.match?(/middleware.*not found/i)
123
- end
124
-
125
- # Resets the insertion flag. Primarily used for testing to allow
126
- # multiple insertion attempts within the same process.
127
- #
128
- # @return [void]
129
255
  def self.reset_middleware_insertion_flag!
130
256
  self.middleware_insertion_attempted = false
131
257
  end
132
258
 
133
- WARNING_MESSAGE = <<~MSG
134
- [verikloak-audience] Skipping automatic middleware insertion because ::Verikloak::Middleware
135
- is not present in the Rails middleware stack.
136
-
137
- To enable verikloak-audience, first ensure that the core Verikloak middleware (`Verikloak::Middleware`)
138
- is added to your Rails middleware stack. Once the core middleware is present, you can run
139
- `rails g verikloak:audience:install` to generate the initializer for the audience middleware,
140
- or manually add:
141
-
142
- config.middleware.insert_after Verikloak::Middleware, Verikloak::Audience::Middleware
143
-
144
- This warning will disappear once the core middleware is properly configured and the audience
145
- middleware is inserted.
146
- MSG
147
-
148
- # Logs a warning message when the core Verikloak middleware is missing
149
- # from the Rails middleware stack. Uses the Rails logger if available,
150
- # otherwise falls back to Kernel.warn for output.
151
- #
152
- # This method is called when automatic middleware insertion is skipped
153
- # due to the absence of the required core middleware.
154
- #
155
- # @return [void]
156
- def self.warn_missing_core_middleware
157
- logger = (::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger))
158
-
159
- if logger
160
- logger.warn(WARNING_MESSAGE)
161
- else
162
- Kernel.warn(WARNING_MESSAGE)
163
- end
164
- end
165
-
166
- # Rails short commands (`g`, `d`) are stripped from ARGV fairly early in
167
- # the boot process. Treat `verikloak:*:install` generators as safe so they
168
- # can run before configuration files exist.
169
- COMMANDS_SKIPPING_VALIDATION = %w[generate g destroy d].freeze
170
-
171
- # Detect whether Rails is currently executing a generator-style command.
172
- # Generators boot the application before configuration exists, so we
173
- # temporarily skip validation to let the install task complete.
174
- #
175
- # @return [Boolean]
176
259
  def self.skip_configuration_validation?
177
260
  tokens = first_cli_tokens
178
261
  return false if tokens.empty?
179
262
 
180
- command = tokens.first
181
- return true if COMMANDS_SKIPPING_VALIDATION.include?(command)
263
+ return true if COMMANDS_SKIPPING_VALIDATION.include?(tokens.first)
182
264
 
183
265
  tokens.any? { |token| verikloak_install_generator?(token) }
184
266
  end
185
267
 
186
- # Capture the first non-option arguments passed to the Rails CLI,
187
- # ignoring wrapper tokens such as "rails". Only the first two tokens are
188
- # relevant for generator detection, so we keep the return list short.
189
- #
190
- # @return [Array<String>] ordered CLI tokens that may signal a generator
191
- def self.first_cli_tokens
192
- tokens = []
193
-
194
- ARGV.each do |arg|
195
- next if arg.start_with?('-')
196
- next if arg == 'rails'
197
-
198
- tokens << arg
199
- break if tokens.size >= 2
200
- end
201
-
202
- tokens
203
- end
204
-
205
- # Detect whether the provided CLI token refers to a Verikloak install
206
- # generator (e.g. `verikloak:install`, `verikloak:pundit:install`).
207
- #
208
- # @param command [String, nil] first non-option argument from ARGV
209
- # @return [Boolean]
210
- def self.verikloak_install_generator?(command)
211
- return false unless command.is_a?(String)
212
-
213
- command.start_with?('verikloak:') && command.end_with?(':install')
268
+ def self.skip_unconfigured_validation?
269
+ audiences_unconfigured?
214
270
  end
215
271
 
216
- class << self
217
- # Synchronize configuration with verikloak-rails when it is present.
218
- # Aligns env_claims_key, required_aud, and resource_client defaults so
219
- # that both gems operate on the same Rack env payload and audience list.
220
- #
221
- # @return [void]
222
- def apply_verikloak_rails_configuration
223
- rails_config = verikloak_rails_config
224
- return unless rails_config
225
-
226
- Verikloak::Audience.configure do |cfg|
227
- sync_env_claims_key(cfg, rails_config)
228
- sync_required_aud(cfg, rails_config)
229
- sync_resource_client(cfg, rails_config)
230
- end
231
- end
232
-
233
- private
234
-
235
- # Resolve the verikloak-rails configuration object if the gem is loaded.
236
- #
237
- # @return [Verikloak::Rails::Configuration, nil]
238
- def verikloak_rails_config
239
- return unless defined?(::Verikloak::Rails)
240
- return unless ::Verikloak::Rails.respond_to?(:config)
272
+ def self.audiences_unconfigured?
273
+ audiences = Verikloak::Audience.config.required_aud_list
241
274
 
242
- ::Verikloak::Rails.config
243
- rescue StandardError
244
- nil
275
+ if audiences.empty?
276
+ warn_unconfigured_once
277
+ return true
245
278
  end
246
279
 
247
- # Align the environment claims key with the one configured in verikloak-rails.
248
- #
249
- # @param cfg [Verikloak::Audience::Configuration]
250
- # @param rails_config [Verikloak::Rails::Configuration]
251
- # @return [void]
252
- def sync_env_claims_key(cfg, rails_config)
253
- return unless rails_config.respond_to?(:user_env_key)
254
-
255
- user_key = rails_config.user_env_key
256
- return if blank?(user_key)
257
-
258
- current = cfg.env_claims_key
259
- return unless current.nil? || current == Verikloak::Audience::Configuration::DEFAULT_ENV_CLAIMS_KEY
260
-
261
- cfg.env_claims_key = user_key
262
- end
263
-
264
- # Populate required audiences from the verikloak-rails configuration when absent.
265
- #
266
- # @param cfg [Verikloak::Audience::Configuration]
267
- # @param rails_config [Verikloak::Rails::Configuration]
268
- # @return [void]
269
- def sync_required_aud(cfg, rails_config)
270
- return unless cfg_required_aud_blank?(cfg)
271
- return unless rails_config.respond_to?(:audience)
272
-
273
- audiences = normalized_audiences(rails_config.audience)
274
- return if audiences.empty?
275
-
276
- cfg.required_aud = audiences.size == 1 ? audiences.first : audiences
277
- end
278
-
279
- # Infer the resource client based on the configured audience when possible.
280
- #
281
- # @param cfg [Verikloak::Audience::Configuration]
282
- # @param rails_config [Verikloak::Rails::Configuration]
283
- # @return [void]
284
- def sync_resource_client(cfg, rails_config)
285
- return unless rails_config.respond_to?(:audience)
286
-
287
- audiences = normalized_audiences(rails_config.audience)
288
- return unless audiences.size == 1
289
-
290
- current_client = cfg.resource_client
291
- unless blank?(current_client) || current_client == Verikloak::Audience::Configuration::DEFAULT_RESOURCE_CLIENT
292
- return
293
- end
294
-
295
- cfg.resource_client = audiences.first
296
- end
297
-
298
- # Determine whether the audience configuration is effectively empty.
299
- #
300
- # @param cfg [Verikloak::Audience::Configuration]
301
- # @return [Boolean]
302
- def cfg_required_aud_blank?(cfg)
303
- value_blank?(cfg.required_aud)
304
- end
305
-
306
- # Generic blank? helper that tolerates nil, empty, or blank-ish values.
307
- #
308
- # @param value [Object]
309
- # @return [Boolean]
310
- def value_blank?(value)
311
- return true if value.nil?
312
- return true if value.respond_to?(:empty?) && value.empty?
313
-
314
- value.to_s.empty?
315
- end
280
+ false
281
+ end
316
282
 
317
- alias blank? value_blank?
283
+ def self.warn_unconfigured_once
284
+ return if unconfigured_warning_emitted
318
285
 
319
- # Coerce the given source into an array of non-empty string audiences.
320
- #
321
- # @param source [Object]
322
- # @return [Array<String>]
323
- def normalized_audiences(source)
324
- Array(source).compact.map(&:to_s).reject(&:empty?)
325
- end
286
+ self.unconfigured_warning_emitted = true
287
+ warn_unconfigured
326
288
  end
327
289
  end
328
290
  end
@@ -4,6 +4,6 @@ module Verikloak
4
4
  module Audience
5
5
  # Current gem version.
6
6
  # @return [String]
7
- VERSION = '0.3.0'
7
+ VERSION = '0.3.1'
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak-audience
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -76,7 +76,7 @@ metadata:
76
76
  source_code_uri: https://github.com/taiyaky/verikloak-audience
77
77
  changelog_uri: https://github.com/taiyaky/verikloak-audience/blob/main/CHANGELOG.md
78
78
  bug_tracker_uri: https://github.com/taiyaky/verikloak-audience/issues
79
- documentation_uri: https://rubydoc.info/gems/verikloak-audience/0.3.0
79
+ documentation_uri: https://rubydoc.info/gems/verikloak-audience/0.3.1
80
80
  rubygems_mfa_required: 'true'
81
81
  rdoc_options: []
82
82
  require_paths: