verikloak-audience 1.0.0 → 1.1.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: 2d271ae972e5464242fbc23bd697f351c87f3ffb84379ba7ea4b17f52a477de7
4
- data.tar.gz: 0cc935edcc2536918f7e6f44971e971e1dcf0b4a73ae9bb75b0c3c99c0eec180
3
+ metadata.gz: 14ece6bb132264abcd755a0904c92d4f8b4f63b3615e25dd76c9101b2af04a96
4
+ data.tar.gz: f546f6e3510e3453a62726cf6b2304653f78d9939f500a0ee2fff076332dd327
5
5
  SHA512:
6
- metadata.gz: d78f43e41a67e402572643a5586e3b438498612f4d413a3462fcf7688085989018417f521af4a63fff651c256e3257e3aad3e15c1a1bca4e5127e7eae096656f
7
- data.tar.gz: 1d1ed067177f17426399c27394e083c9231fbc6838ab0f90d99936e1e25182c07f5eb1b00c73780b88611565a96c7aa2ad854cb4d029d420a28ff54de2f10362
6
+ metadata.gz: 27062866e3d84df87c309ca11d29cdcd39b6b85909a572498c12ba88cce3d7ff135963de5fb0b560a8380340872dda10c1a81376962b86bcb3394398aab8567a
7
+ data.tar.gz: '08c8ea868d6db70f9f738a6967d49276bd9b72473c528a0302ea4f4aeadbe6c383ae7cdd153c25a7dcb2857ca35be26f5abc87eac2764fd12b859204536c0390'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.1.0] - 2026-07-03
11
+
12
+ ### Added
13
+ - **Boot-time profile validation**: `Configuration#validate!` now rejects unknown `profile` values, so misspellings (e.g. `:strict_signle` instead of `:strict_single`) fail fast at startup instead of raising on the first request
14
+ - **`Configuration#normalized_profile` / `#validated_profile`**: Shared profile coercion and validation helpers (Symbol coercion, default fallback, unknown-profile check) used by both `validate!` and `Checker`
15
+ - **JSON 500 for request-time configuration errors**: When a `ConfigurationError` surfaces during a request (normally prevented by boot-time validation), the middleware now renders a JSON error response (`audience_configuration_error`, status 500) instead of leaking the exception through the Rack stack; the response body stays generic and the failure detail is written to the request logger
16
+ - **`Checker.observed_audiences`**: Public helper returning the normalized audience values seen in a claims payload, used by the middleware so rejection logs show the same audiences the checker evaluated (including duck-typed `#to_hash` claims)
17
+ - **CI Ruby matrix**: RSpec now runs against Ruby 3.1 / 3.2 / 3.3 / 3.4 (all series supported by the gemspec) — the Docker-based job covers the development Ruby (3.4) and a non-Docker `ruby/setup-ruby` matrix covers 3.1–3.3, mirroring verikloak core's CI layout
18
+ - **Coverage floor**: SimpleCov now enforces a minimum line coverage of 90% in CI (not enforced for focused local runs)
19
+ - **MAINTAINERS.md**: Release instructions referenced from the README now exist
20
+ - Tests for the `Forbidden`/`Error`/`ConfigurationError` classes, the Railtie `discovery_url` guard, the `REQUEST_PATH` fallback, and Symbol inputs to the public checker predicates
21
+
22
+ ### Changed
23
+ - **`Checker.suggest` accepts a `fallback:` keyword** (default `:strict_single`, preserving the 1.0 always-Symbol contract): the middleware passes `fallback: nil`, so rejection logs read `no profile matches the observed aud` instead of naming a misleading suggestion
24
+ - **Middleware boot-time validation now evaluates the middleware's own (post-override) configuration**, so options passed via `config.middleware.use` are validated at startup even when the global configuration is untouched
25
+ - Values synced from verikloak-rails now follow its own resolution rules: `user_env_key` is whitespace-stripped and blank values are treated as unset (both middlewares then share the `verikloak.user` default, mirroring `effective_user_env_key` in verikloak-rails >= 1.2), and blank `required_aud`/`resource_client` are treated as unset
26
+ - Runtime dependency raised to `verikloak ~> 1.1` (was `~> 1.0`); lockfile updated to verikloak 1.1.0
27
+ - `Checker::VALID_PROFILES` is now an alias of `Configuration::VALID_PROFILES` (same values; the canonical definition moved)
28
+ - All public checker predicates (`strict_single?`, `allow_account?`, `any_match?`) now coerce `required` inputs consistently, accepting Symbols and single values
29
+ - `Railtie.skip_validation?` combines the generator-mode and unconfigured checks and takes the configuration under validation as an argument; the middleware and the after-initialize hook now share it
30
+
31
+ ### Fixed
32
+ - **CI coverage was never measured**: The `SIMPLECOV` environment variable set in GitHub Actions never reached the Docker container; `compose.yml` now passes it through, so coverage reports are actually generated in CI
33
+ - Removed dead code paths (`env_key&.to_sym` safe navigation, unreachable `nil` check in `sync_env_claims_key`) and redundant `module_function` declarations in `Checker`
34
+
35
+ ---
36
+
10
37
  ## [1.0.0] - 2026-02-15
11
38
 
12
39
  ### Fixed
data/ERRORS.md ADDED
@@ -0,0 +1,48 @@
1
+ # Verikloak Audience Error Reference
2
+
3
+ This document captures the *current* error behaviour implemented by the `verikloak-audience` gem. Use it as a developer aid when wiring the middleware or surfacing audience failures to callers.
4
+
5
+ ## Response Shape
6
+ - Status: `403 Forbidden` (or `500 Internal Server Error` for configuration failures, see below)
7
+ - Headers: `Content-Type: application/json`
8
+ - Body: `{ "error": <code>, "message": <text> }`
9
+ - Authentication headers: none added by this gem (the upstream Verikloak middleware already emits `WWW-Authenticate`).
10
+
11
+ Example 403 response:
12
+ ```http
13
+ HTTP/1.1 403 Forbidden
14
+ Content-Type: application/json
15
+
16
+ {"error":"insufficient_audience","message":"Audience not acceptable for profile strict_single"}
17
+ ```
18
+
19
+ ## Error Catalog
20
+
21
+ | Code | HTTP | Message template | Trigger |
22
+ |--------------------------------|------|----------------------------------------------------|---------|
23
+ | `insufficient_audience` | 403 | `Audience not acceptable for profile <profile>` | Returned whenever the configured audience profile does not match the claims loaded from `env[config.env_claims_key]`. |
24
+ | `audience_configuration_error` | 500 | `audience configuration error` | Returned when a configuration problem (e.g. an unknown `profile`) is detected while evaluating a request. The body stays generic; the failure detail (e.g. the invalid profile value) is written to the request logger only. Boot-time validation (`Configuration#validate!`) normally catches this before any request is served; this response guards deployments where validation was skipped (e.g. an unconfigured Rails boot). |
25
+
26
+ ### Trigger details
27
+ - Claims are read from `env[config.env_claims_key]` (default: `"verikloak.user"`). Missing claims are treated as `{}`.
28
+ - Validation is delegated to `Verikloak::Audience::Checker.ok?`, which supports profiles `:strict_single`, `:allow_account`, `:any_match`, and `:resource_or_aud`.
29
+ - When validation fails and `config.suggest_in_logs` is true (default), the middleware emits a `warn` message to STDERR such as:
30
+ ```
31
+ [verikloak-audience] insufficient_audience; suggestion profile=:allow_account aud=["my-api","account"]
32
+ ```
33
+ When no profile would accept the observed claims, the log reads `no profile matches the observed aud` instead of naming a profile. This logging side-effect has no impact on the HTTP response.
34
+
35
+ ## Exception Classes
36
+
37
+ | Class | Default code | Default status | Notes |
38
+ |--------------------------------------|----------------------------|----------------|-------|
39
+ | `Verikloak::Audience::Error` | `audience_error` | 403 | Base class exposing `#code` and `#http_status`; not raised automatically by the middleware but available for host apps. |
40
+ | `Verikloak::Audience::Forbidden` | `insufficient_audience` | 403 | Thin wrapper around `Error`. Useful if your application raises exceptions instead of returning JSON responses directly. |
41
+ | `Verikloak::Audience::ConfigurationError` | `audience_configuration_error` | 500 | Raised by `Configuration#validate!` and configuration writers when settings are missing or inconsistent; rendered as the 500 JSON response if it surfaces during a request. |
42
+
43
+ These classes exist for integration purposes (e.g., controller helpers) and mirror the JSON shape returned by the Rack middleware. If you rescue them inside a Rails app, you can map them to the same 403 payload shown above.
44
+
45
+ ## Operational Notes
46
+ - Audience mismatches always resolve to the single 403 response. The only other status this gem emits is the 500 `audience_configuration_error` response described above, which signals a misconfiguration rather than a client failure.
47
+ - Upstream Verikloak middleware remains responsible for token verification errors (401/503/etc.). Place `Verikloak::Audience::Middleware` after it so that verified claims are available for audience checks.
48
+ - Adjust `config.required_aud`, `config.profile`, and `config.resource_client` to control what qualifies as a passing audience.
data/MAINTAINERS.md ADDED
@@ -0,0 +1,42 @@
1
+ # Maintainers Guide
2
+
3
+ Release instructions for `verikloak-audience`. For contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).
4
+
5
+ ## Prerequisites
6
+
7
+ - Push access to this repository and ownership of the gem on [RubyGems.org](https://rubygems.org/gems/verikloak-audience)
8
+ - MFA enabled on your RubyGems account (`rubygems_mfa_required` is set in the gemspec, so pushes without MFA are rejected)
9
+
10
+ ## Release checklist
11
+
12
+ 1. Ensure `main` is green (RSpec across all supported Rubies, RuboCop, bundler-audit).
13
+ 2. Bump the version in `lib/verikloak/audience/version.rb` following [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
+ 3. Add a dated entry to `CHANGELOG.md` describing the changes (Keep a Changelog format).
15
+ 4. Update `Gemfile.lock` so the lockfile records the new version:
16
+ ```bash
17
+ docker compose run --rm dev bundle install
18
+ ```
19
+ 5. Run the full test suite one more time:
20
+ ```bash
21
+ docker compose run --rm dev rspec
22
+ docker compose run --rm dev rubocop
23
+ ```
24
+ 6. Commit the release changes and open a pull request. Merge once CI passes.
25
+ 7. Tag the merge commit on `main` and push the tag:
26
+ ```bash
27
+ git tag v<VERSION>
28
+ git push origin v<VERSION>
29
+ ```
30
+ 8. Build and publish the gem:
31
+ ```bash
32
+ gem build verikloak-audience.gemspec
33
+ gem push verikloak-audience-<VERSION>.gem
34
+ ```
35
+ 9. Verify the release on [RubyGems.org](https://rubygems.org/gems/verikloak-audience) and check that
36
+ [rubydoc.info](https://rubydoc.info/gems/verikloak-audience) picks up the new version.
37
+
38
+ ## Compatibility policy
39
+
40
+ - Keep the runtime dependency pinned to the current minor floor of the core gem (currently `verikloak ~> 1.1`).
41
+ - Supported Ruby versions follow the gemspec (`>= 3.1`); the CI matrix must cover every supported series.
42
+ - Coordinate breaking changes with the other gems in the Verikloak family (verikloak, verikloak-rails, verikloak-bff, verikloak-pundit) so error hierarchies and Rack env keys stay aligned.
data/README.md CHANGED
@@ -69,7 +69,7 @@ See [`examples/rack.ru`](examples/rack.ru) for a full Rack sample. In Rails, alw
69
69
  `env_claims_key` assumes the preceding `Verikloak::Middleware` populates the Rack env. If the middleware order changes, claims will be missing and the audience check will always reject.
70
70
 
71
71
  ### Operational safeguards
72
- - Middleware initialisation now fails fast when `required_aud` is empty. When Rails loads via the supplied Railtie, `Verikloak::Audience.config.validate!` runs after boot so configuration mistakes surface during startup instead of returning 403 for every request.
72
+ - Middleware initialisation now fails fast when `required_aud` is empty or `profile` is not one of the supported profiles. When Rails loads via the supplied Railtie, `Verikloak::Audience.config.validate!` runs after boot so configuration mistakes surface during startup instead of returning 403 for every request. If a configuration problem still slips through to request time (e.g. validation was skipped during an unconfigured boot), the middleware responds with a generic JSON 500 `audience_configuration_error` and logs the failure detail server-side instead of leaking an exception.
73
73
  - When audience validation fails, the middleware consults `env['verikloak.logger']`, `env['rack.logger']`, and `env['action_dispatch.logger']` (in that order) before falling back to Ruby's `Kernel#warn`, keeping failure logs consistent with Rails and Verikloak observers.
74
74
  - For the `:resource_or_aud` profile, `resource_client` must match one of the values in `required_aud`. A single-element `required_aud` automatically infers the client id, ensuring the same client identifier is shared with downstream BFF/Pundit integrations.
75
75
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'verikloak/audience/configuration'
4
+
3
5
  module Verikloak
4
6
  module Audience
5
7
  # Audience profile checker functions.
@@ -7,7 +9,9 @@ module Verikloak
7
9
  # This module provides predicate helpers used by the middleware to decide
8
10
  # whether a given set of claims satisfies the configured profile.
9
11
  module Checker
10
- VALID_PROFILES = %i[strict_single allow_account any_match resource_or_aud].freeze
12
+ # Kept for backward compatibility; the canonical definition lives in
13
+ # {Verikloak::Audience::Configuration::VALID_PROFILES}.
14
+ VALID_PROFILES = Verikloak::Audience::Configuration::VALID_PROFILES
11
15
 
12
16
  module_function
13
17
 
@@ -15,18 +19,11 @@ module Verikloak
15
19
  #
16
20
  # @param claims [Hash] OIDC claims (expects keys like "aud", "resource_access")
17
21
  # @param cfg [Verikloak::Audience::Configuration]
22
+ # @raise [ConfigurationError] when the configured profile is unknown
18
23
  # @return [Boolean]
19
24
  def ok?(claims, cfg)
20
25
  claims = normalize_claims(claims)
21
-
22
- profile = cfg.profile
23
- profile = profile.to_sym if profile.respond_to?(:to_sym)
24
- profile = :strict_single if profile.nil?
25
-
26
- unless VALID_PROFILES.include?(profile)
27
- raise Verikloak::Audience::ConfigurationError,
28
- "unknown audience profile #{cfg.profile.inspect}"
29
- end
26
+ profile = cfg.validated_profile
30
27
 
31
28
  case profile
32
29
  when :strict_single
@@ -43,23 +40,25 @@ module Verikloak
43
40
  # Validate that aud matches required exactly (order-insensitive).
44
41
  #
45
42
  # @param claims [Hash]
46
- # @param required [Array<String>]
43
+ # @param required [Array<String,Symbol>, String, Symbol]
47
44
  # @return [Boolean]
48
45
  def strict_single?(claims, required)
49
46
  aud = normalized_audiences(claims)
47
+ required = normalized_required(required)
50
48
  return false if required.empty?
51
49
 
52
50
  # Must contain all required and have no unexpected extra (order-insensitive)
53
- (aud.sort == required.map(&:to_s).sort)
51
+ aud.sort == required.sort
54
52
  end
55
53
 
56
54
  # Validate aud allowing "account" as an extra value.
57
55
  #
58
56
  # @param claims [Hash]
59
- # @param required [Array<String>]
57
+ # @param required [Array<String,Symbol>, String, Symbol]
60
58
  # @return [Boolean]
61
59
  def allow_account?(claims, required)
62
60
  aud = normalized_audiences(claims)
61
+ required = normalized_required(required)
63
62
  return false if required.empty?
64
63
 
65
64
  # Permit 'account' extra
@@ -72,14 +71,15 @@ module Verikloak
72
71
  # More permissive than :strict_single; useful when multiple clients share audiences.
73
72
  #
74
73
  # @param claims [Hash]
75
- # @param required [Array<String>]
74
+ # @param required [Array<String,Symbol>, String, Symbol]
76
75
  # @return [Boolean]
77
76
  def any_match?(claims, required)
78
77
  aud = normalized_audiences(claims)
78
+ required = normalized_required(required)
79
79
  return false if required.empty?
80
80
 
81
81
  # At least one of the required audiences must be present
82
- aud.intersect?(required.map(&:to_s))
82
+ aud.intersect?(required)
83
83
  end
84
84
 
85
85
  # Permit when resource roles exist for the client; otherwise fallback to
@@ -87,7 +87,7 @@ module Verikloak
87
87
  #
88
88
  # @param claims [Hash]
89
89
  # @param client [String]
90
- # @param required [Array<String>]
90
+ # @param required [Array<String,Symbol>, String, Symbol]
91
91
  # @return [Boolean]
92
92
  def resource_or_aud?(claims, client, required)
93
93
  roles = Array(claims.dig('resource_access', client, 'roles')).compact.reject { |r| r.to_s.empty? }
@@ -101,8 +101,11 @@ module Verikloak
101
101
  #
102
102
  # @param claims [Hash]
103
103
  # @param cfg [Verikloak::Audience::Configuration]
104
- # @return [:strict_single, :allow_account, :resource_or_aud]
105
- def suggest(claims, cfg)
104
+ # @param fallback [Symbol, nil] value returned when no profile accepts
105
+ # the claims (defaults to :strict_single, preserving the 1.0 contract)
106
+ # @return [:strict_single, :allow_account, :any_match, :resource_or_aud, nil]
107
+ # the most fitting profile, or the fallback when none accepts
108
+ def suggest(claims, cfg, fallback: :strict_single)
106
109
  claims = normalize_claims(claims)
107
110
 
108
111
  required = cfg.required_aud_list
@@ -111,7 +114,16 @@ module Verikloak
111
114
  return :any_match if any_match?(claims, required)
112
115
  return :resource_or_aud if resource_or_aud?(claims, cfg.resource_client.to_s, required)
113
116
 
114
- :strict_single
117
+ fallback
118
+ end
119
+
120
+ # Audience values observed in the claims, normalized exactly like the
121
+ # profile checks normalize them (useful for consistent logging).
122
+ #
123
+ # @param claims [Object] raw claims value (Hash or #to_hash)
124
+ # @return [Array<String>]
125
+ def observed_audiences(claims)
126
+ normalized_audiences(normalize_claims(claims))
115
127
  end
116
128
 
117
129
  # Normalize incoming claims to a Hash to guard against unexpected
@@ -133,7 +145,6 @@ module Verikloak
133
145
  warn "[Verikloak::Audience] normalize_claims failed: #{e.class}: #{e.message}" if $DEBUG
134
146
  {}
135
147
  end
136
- module_function :normalize_claims
137
148
  private_class_method :normalize_claims
138
149
 
139
150
  # Normalize audience claims into a predictable array of strings.
@@ -143,8 +154,17 @@ module Verikloak
143
154
  def normalized_audiences(claims)
144
155
  Array(claims['aud']).map(&:to_s)
145
156
  end
146
- module_function :normalized_audiences
147
157
  private_class_method :normalized_audiences
158
+
159
+ # Coerce a required-audience input into an array of strings so that the
160
+ # public predicates accept Symbols and single values consistently.
161
+ #
162
+ # @param required [Array<String,Symbol>, String, Symbol, nil]
163
+ # @return [Array<String>]
164
+ def normalized_required(required)
165
+ Array(required).map(&:to_s)
166
+ end
167
+ private_class_method :normalized_required
148
168
  end
149
169
  end
150
170
  end
@@ -28,6 +28,10 @@ module Verikloak
28
28
  class Configuration
29
29
  DEFAULT_RESOURCE_CLIENT = 'rails-api'
30
30
  DEFAULT_ENV_CLAIMS_KEY = 'verikloak.user'
31
+ DEFAULT_PROFILE = :strict_single
32
+
33
+ # Enforcement profiles supported by {Verikloak::Audience::Checker}.
34
+ VALID_PROFILES = %i[strict_single allow_account any_match resource_or_aud].freeze
31
35
 
32
36
  attr_accessor :profile, :required_aud, :resource_client,
33
37
  :suggest_in_logs, :skip_paths
@@ -37,7 +41,7 @@ module Verikloak
37
41
  #
38
42
  # @return [void]
39
43
  def initialize
40
- @profile = :strict_single
44
+ @profile = DEFAULT_PROFILE
41
45
  @required_aud = []
42
46
  @resource_client = DEFAULT_RESOURCE_CLIENT
43
47
  self.env_claims_key = DEFAULT_ENV_CLAIMS_KEY
@@ -66,6 +70,35 @@ module Verikloak
66
70
  Array(required_aud).map(&:to_s)
67
71
  end
68
72
 
73
+ # Coerce `profile` into a Symbol, falling back to the default when unset.
74
+ # Values that cannot be symbolized (e.g. an Integer) are returned as-is
75
+ # so {#validated_profile} can reject them with a precise error. The
76
+ # result is not guaranteed to be a member of {VALID_PROFILES}; use
77
+ # {#validated_profile} to enforce that.
78
+ #
79
+ # @return [Symbol, Object]
80
+ def normalized_profile
81
+ value = profile
82
+ value = value.to_sym if value.respond_to?(:to_sym)
83
+ value.nil? ? DEFAULT_PROFILE : value
84
+ end
85
+
86
+ # Coerce `profile` into a Symbol and ensure it is one of
87
+ # {VALID_PROFILES}. Single source of the unknown-profile check shared
88
+ # by {#validate!} and {Verikloak::Audience::Checker.ok?}.
89
+ #
90
+ # @raise [ConfigurationError] when the profile is unknown
91
+ # @return [Symbol]
92
+ def validated_profile
93
+ profile_name = normalized_profile
94
+ unless VALID_PROFILES.include?(profile_name)
95
+ raise Verikloak::Audience::ConfigurationError,
96
+ "unknown audience profile #{profile.inspect}"
97
+ end
98
+
99
+ profile_name
100
+ end
101
+
69
102
  # @param value [#to_s, nil]
70
103
  # @raise [ConfigurationError] when value is nil or blank
71
104
  # @return [void]
@@ -82,6 +115,9 @@ module Verikloak
82
115
 
83
116
  # Validate the configuration to ensure required values are present.
84
117
  #
118
+ # @raise [ConfigurationError] when `required_aud` is empty, `profile` is
119
+ # not one of {VALID_PROFILES}, or `resource_client` conflicts with
120
+ # `required_aud` under the `:resource_or_aud` profile
85
121
  # @return [Configuration] the validated configuration
86
122
  def validate!
87
123
  audiences = required_aud_list
@@ -90,9 +126,7 @@ module Verikloak
90
126
  'required_aud must include at least one audience'
91
127
  end
92
128
 
93
- profile_name = profile
94
- profile_name = profile_name.to_sym if profile_name.respond_to?(:to_sym)
95
- profile_name ||= :strict_single
129
+ profile_name = validated_profile
96
130
 
97
131
  ensure_resource_client!(audiences) if profile_name == :resource_or_aud
98
132
 
@@ -42,16 +42,15 @@ module Verikloak
42
42
  path = env['PATH_INFO'] || env['REQUEST_PATH'] || ''
43
43
  return @app.call(env) if skip?(path)
44
44
 
45
- env_key = @config.env_claims_key
46
- claims = env[env_key] || env[env_key&.to_sym] || {}
47
- return @app.call(env) if Checker.ok?(claims, @config)
48
-
49
- if @config.suggest_in_logs
50
- suggestion = Checker.suggest(claims, @config)
51
- aud_view = Array(claims['aud']).inspect
52
- log_warning(env,
53
- "[verikloak-audience] insufficient_audience; suggestion profile=:#{suggestion} aud=#{aud_view}")
45
+ claims = read_claims(env)
46
+ begin
47
+ authorized = Checker.ok?(claims, @config)
48
+ rescue Verikloak::Audience::ConfigurationError => e
49
+ return configuration_error_response(env, e)
54
50
  end
51
+ return @app.call(env) if authorized
52
+
53
+ log_rejection(env, claims)
55
54
 
56
55
  Verikloak::ErrorResponse.build(
57
56
  code: 'insufficient_audience',
@@ -62,6 +61,50 @@ module Verikloak
62
61
 
63
62
  private
64
63
 
64
+ # Read verified claims from the Rack env under the configured key.
65
+ #
66
+ # @param env [Hash] Rack environment
67
+ # @return [Object] raw claims value (normalized later by {Checker})
68
+ def read_claims(env)
69
+ env_key = @config.env_claims_key
70
+ env[env_key] || env[env_key.to_sym] || {}
71
+ end
72
+
73
+ # Log a configuration failure and render it as a generic JSON error
74
+ # response instead of leaking a raw exception through the Rack stack.
75
+ # The failure detail goes to the server log only, never to the client.
76
+ # Boot-time validation normally prevents this path; it guards
77
+ # deployments where validation was skipped (e.g. unconfigured Rails
78
+ # boot).
79
+ #
80
+ # @param env [Hash] Rack environment
81
+ # @param error [Verikloak::Audience::ConfigurationError]
82
+ # @return [Array(Integer, Hash, #each)] Rack response triple
83
+ def configuration_error_response(env, error)
84
+ log_warning(env, "[verikloak-audience] configuration error: #{error.message}")
85
+
86
+ Verikloak::ErrorResponse.build(
87
+ code: error.code,
88
+ message: 'audience configuration error',
89
+ status: error.http_status
90
+ )
91
+ end
92
+
93
+ # Emit the failure log (with a profile suggestion when one fits) if
94
+ # `suggest_in_logs` is enabled.
95
+ #
96
+ # @param env [Hash] Rack environment
97
+ # @param claims [Object] raw claims value read from the env
98
+ # @return [void]
99
+ def log_rejection(env, claims)
100
+ return unless @config.suggest_in_logs
101
+
102
+ suggestion = Checker.suggest(claims, @config, fallback: nil)
103
+ detail = suggestion ? "suggestion profile=:#{suggestion}" : 'no profile matches the observed aud'
104
+ aud_view = Checker.observed_audiences(claims).inspect
105
+ log_warning(env, "[verikloak-audience] insufficient_audience; #{detail} aud=#{aud_view}")
106
+ end
107
+
65
108
  # Apply provided options to the configuration instance.
66
109
  #
67
110
  # @param opts [Hash] raw overrides provided to the middleware
@@ -82,23 +125,17 @@ module Verikloak
82
125
  # Determine whether configuration validation should run. This allows
83
126
  # Rails generators to boot without a fully-populated configuration since
84
127
  # the install task is responsible for creating it. Also skips validation
85
- # when configuration is not explicitly set up.
128
+ # when configuration is not explicitly set up. The middleware's own
129
+ # (post-override) configuration decides the unconfigured case, so
130
+ # options passed to `use` are validated even when the global
131
+ # configuration is untouched.
86
132
  #
87
133
  # @return [Boolean]
88
134
  def skip_validation?
89
- # Skip if Railtie indicates generator mode
90
- if defined?(::Verikloak::Audience::Railtie)
91
- if ::Verikloak::Audience::Railtie.respond_to?(:skip_configuration_validation?) && ::Verikloak::Audience::Railtie.skip_configuration_validation?
92
- return true
93
- end
94
-
95
- # Skip if configuration is incomplete (no audiences configured)
96
- if ::Verikloak::Audience::Railtie.respond_to?(:skip_unconfigured_validation?) && ::Verikloak::Audience::Railtie.skip_unconfigured_validation?
97
- return true
98
- end
99
- end
135
+ return false unless defined?(::Verikloak::Audience::Railtie)
100
136
 
101
- false
137
+ railtie = ::Verikloak::Audience::Railtie
138
+ railtie.respond_to?(:skip_validation?) && railtie.skip_validation?(@config)
102
139
  end
103
140
 
104
141
  # Emit a warning for failed audience checks using request-scoped loggers
@@ -69,11 +69,7 @@ module Verikloak
69
69
  end
70
70
 
71
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
72
+ !value_blank?(value)
77
73
  end
78
74
 
79
75
  def middleware_not_found_error?(error)
@@ -116,11 +112,16 @@ module Verikloak
116
112
  def sync_env_claims_key(cfg, rails_config)
117
113
  return unless rails_config.respond_to?(:user_env_key)
118
114
 
119
- user_key = rails_config.user_env_key
120
- return if blank?(user_key)
115
+ # Resolve the key the same way verikloak-rails' #effective_user_env_key
116
+ # does (>= 1.2): strip whitespace and treat blank as unset. The core
117
+ # middleware then falls back to its default env key, which matches our
118
+ # default, so both middlewares stay aligned on the same key.
119
+ user_key = rails_config.user_env_key.to_s.strip
120
+ return if user_key.empty?
121
121
 
122
- current = cfg.env_claims_key
123
- return unless current.nil? || current == Verikloak::Audience::Configuration::DEFAULT_ENV_CLAIMS_KEY
122
+ # env_claims_key is never nil (its writer rejects nil), so only the
123
+ # untouched default is eligible for syncing.
124
+ return unless cfg.env_claims_key == Verikloak::Audience::Configuration::DEFAULT_ENV_CLAIMS_KEY
124
125
 
125
126
  cfg.env_claims_key = user_key
126
127
  end
@@ -163,6 +164,7 @@ module Verikloak
163
164
 
164
165
  def value_blank?(value)
165
166
  return true if value.nil?
167
+ return value.blank? if value.respond_to?(:blank?)
166
168
  return true if value.respond_to?(:empty?) && value.empty?
167
169
 
168
170
  value.to_s.empty?
@@ -213,8 +215,7 @@ module Verikloak
213
215
 
214
216
  initializer 'verikloak_audience.configuration' do
215
217
  config.after_initialize do
216
- next if Verikloak::Audience::Railtie.skip_configuration_validation?
217
- next if Verikloak::Audience::Railtie.skip_unconfigured_validation?
218
+ next if Verikloak::Audience::Railtie.skip_validation?
218
219
 
219
220
  Verikloak::Audience.config.validate!
220
221
  end
@@ -256,6 +257,17 @@ module Verikloak
256
257
  self.middleware_insertion_attempted = false
257
258
  end
258
259
 
260
+ # Combined predicate used by the after-initialize hook and the
261
+ # middleware to decide whether configuration validation should run.
262
+ #
263
+ # @param config [Verikloak::Audience::Configuration] configuration whose
264
+ # audiences decide the unconfigured-boot skip; the middleware passes
265
+ # its own (post-override) configuration, the hook uses the global one
266
+ # @return [Boolean]
267
+ def self.skip_validation?(config = Verikloak::Audience.config)
268
+ skip_configuration_validation? || skip_unconfigured_validation?(config)
269
+ end
270
+
259
271
  def self.skip_configuration_validation?
260
272
  tokens = first_cli_tokens
261
273
  return false if tokens.empty?
@@ -265,12 +277,12 @@ module Verikloak
265
277
  tokens.any? { |token| verikloak_install_generator?(token) }
266
278
  end
267
279
 
268
- def self.skip_unconfigured_validation?
269
- audiences_unconfigured?
280
+ def self.skip_unconfigured_validation?(config = Verikloak::Audience.config)
281
+ audiences_unconfigured?(config)
270
282
  end
271
283
 
272
- def self.audiences_unconfigured?
273
- audiences = Verikloak::Audience.config.required_aud_list
284
+ def self.audiences_unconfigured?(config = Verikloak::Audience.config)
285
+ audiences = config.required_aud_list
274
286
 
275
287
  if audiences.empty?
276
288
  warn_unconfigured_once
@@ -4,6 +4,6 @@ module Verikloak
4
4
  module Audience
5
5
  # Current gem version.
6
6
  # @return [String]
7
- VERSION = '1.0.0'
7
+ VERSION = '1.1.0'
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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -35,14 +35,14 @@ dependencies:
35
35
  requirements:
36
36
  - - "~>"
37
37
  - !ruby/object:Gem::Version
38
- version: '1.0'
38
+ version: '1.1'
39
39
  type: :runtime
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - "~>"
44
44
  - !ruby/object:Gem::Version
45
- version: '1.0'
45
+ version: '1.1'
46
46
  description: |
47
47
  Rack middleware that enforces audience checks with deployable profiles,
48
48
  layering on top of Verikloak token verification.
@@ -51,7 +51,9 @@ extensions: []
51
51
  extra_rdoc_files: []
52
52
  files:
53
53
  - CHANGELOG.md
54
+ - ERRORS.md
54
55
  - LICENSE
56
+ - MAINTAINERS.md
55
57
  - README.md
56
58
  - lib/generators/verikloak/audience/install/install_generator.rb
57
59
  - lib/generators/verikloak/audience/install/templates/initializer.rb.erb
@@ -70,7 +72,7 @@ metadata:
70
72
  source_code_uri: https://github.com/taiyaky/verikloak-audience
71
73
  changelog_uri: https://github.com/taiyaky/verikloak-audience/blob/main/CHANGELOG.md
72
74
  bug_tracker_uri: https://github.com/taiyaky/verikloak-audience/issues
73
- documentation_uri: https://rubydoc.info/gems/verikloak-audience/1.0.0
75
+ documentation_uri: https://rubydoc.info/gems/verikloak-audience/1.1.0
74
76
  rubygems_mfa_required: 'true'
75
77
  rdoc_options: []
76
78
  require_paths: