verikloak-audience 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 983947348061c7b7dd379572850ec12de6bddda37b511b03466301b22c15256f
4
- data.tar.gz: 2bd99a2a76fdc020d4f3a51a1db11151d0b38e9429847294f0431abb7e47dd17
3
+ metadata.gz: 0fb9e7977881b01b0bd15bad81ebf1a49e279ae3f5e2cefb9d978c6579c9fceb
4
+ data.tar.gz: 778b0260bb771b2e5d14f18b2cda54ce19567a5ee727f85ade281e8331170e44
5
5
  SHA512:
6
- metadata.gz: 6bca90d90c7d35408009fcd6007ac317115743eda14dc4e8f1e9853a3fdb9350f4cb5db426e032631faf2e4ef9f96a86885942145bad40d7e809b5eb1a748905
7
- data.tar.gz: 3bcc1d7a44a094a3e15d8b33e79fe46ac66e21eb84175799d0469f701522949340e82b43df0d6e029a7396febc9c0ab16a196efdc1cce6bb25358b674019463e
6
+ metadata.gz: 26a8bfcc5c5d714b1376dd88b4e7a27c530806f6d935f8d2b3665b3c7c52d7345782ad1df40a3d7fb63b912e899aa33f27e79ddb6b39c05074b256dfd0c99fe0
7
+ data.tar.gz: 359a268c4ad3567c83dc93f1a6a348f8a8b8c22cb0067fced9c1473cd9285a7b7bb64b4a10b09d397b4de305a28221678238da1bdc7c0adb4ccf2632a18d0232
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.0] - 2025-09-21
11
+
12
+ ### Added
13
+ - README "Operational safeguards" section covering startup validation, logger precedence, and `:resource_or_aud` alignment guidance.
14
+ - YARD documentation for configuration helpers and middleware logging routines.
15
+
16
+ ### Changed
17
+ - Tightened `resource_client` inference/validation to enforce alignment with `required_aud` and infer single-entry clients automatically.
18
+ - Prefer request-scoped loggers over `Kernel#warn` when emitting audience failure messages.
19
+ - Bumped runtime dependency to `verikloak >= 0.1.5` to pick up shared logger support.
20
+ - Improved Rails Railtie test harness to mimic real initializer registration.
21
+
22
+ ## [0.1.1] - 2025-09-20
23
+
24
+ ### Changed
25
+ - Documented `Configuration#safe_dup` behaviour and tightened duplication semantics.
26
+ - Expanded error class YARD docs to clarify operational response codes.
27
+
10
28
  ## [0.1.0] - 2025-09-20
11
29
 
12
30
  ### Added
data/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # verikloak-audience
2
2
 
3
+ [![CI](https://github.com/taiyaky/verikloak-audience/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/taiyaky/verikloak-audience/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/verikloak-audience)](https://rubygems.org/gems/verikloak-audience)
5
+ ![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1-blue)
6
+ [![Downloads](https://img.shields.io/gem/dt/verikloak-audience)](https://rubygems.org/gems/verikloak-audience)
7
+
3
8
  Rack middleware for validating the `aud` claim of Keycloak-issued tokens on top of the Verikloak stack. It ships with deploy-friendly presets that address common Keycloak patterns such as `account` co-existence and `resource_access`-driven role enforcement.
4
9
 
5
10
  For the full error behaviour (response shapes, exception classes, logging hints), see [ERRORS.md](ERRORS.md).
@@ -53,6 +58,11 @@ See [`examples/rack.ru`](examples/rack.ru) for a full Rack sample. In Rails, alw
53
58
 
54
59
  `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.
55
60
 
61
+ ### Operational safeguards
62
+ - 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.
63
+ - 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.
64
+ - 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.
65
+
56
66
  ## Testing
57
67
  All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
58
68
  See the CI badge at the top for current build status.
@@ -7,6 +7,8 @@ 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
11
+
10
12
  module_function
11
13
 
12
14
  # Returns whether the given claims satisfy the configured profile.
@@ -15,8 +17,16 @@ module Verikloak
15
17
  # @param cfg [Verikloak::Audience::Configuration]
16
18
  # @return [Boolean]
17
19
  def ok?(claims, cfg)
18
- profile = cfg.profile.to_sym
19
- profile = :strict_single unless %i[strict_single allow_account resource_or_aud].include?(profile)
20
+ 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
20
30
 
21
31
  case profile
22
32
  when :strict_single
@@ -77,6 +87,8 @@ module Verikloak
77
87
  # @param cfg [Verikloak::Audience::Configuration]
78
88
  # @return [:strict_single, :allow_account, :resource_or_aud]
79
89
  def suggest(claims, cfg)
90
+ claims = normalize_claims(claims)
91
+
80
92
  aud = Array(claims['aud']).map(&:to_s)
81
93
  req = cfg.required_aud_list
82
94
  has_roles = !Array(claims.dig('resource_access', cfg.resource_client.to_s, 'roles')).empty?
@@ -87,6 +99,27 @@ module Verikloak
87
99
 
88
100
  :strict_single
89
101
  end
102
+
103
+ # Normalize incoming claims to a Hash to guard against unexpected
104
+ # env payloads or middleware ordering issues.
105
+ #
106
+ # @param claims [Object]
107
+ # @return [Hash]
108
+ def normalize_claims(claims)
109
+ return {} if claims.nil?
110
+ return claims if claims.is_a?(Hash)
111
+
112
+ if claims.respond_to?(:to_hash)
113
+ coerced = claims.to_hash
114
+ return coerced if coerced.is_a?(Hash)
115
+ end
116
+
117
+ {}
118
+ rescue StandardError
119
+ {}
120
+ end
121
+ module_function :normalize_claims
122
+ private_class_method :normalize_claims
90
123
  end
91
124
  end
92
125
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'verikloak/audience/errors'
4
+
3
5
  module Verikloak
4
6
  module Audience
5
7
  # Configuration holder for verikloak-audience.
@@ -20,8 +22,11 @@ module Verikloak
20
22
  # Whether to log a suggestion when audience validation fails.
21
23
  # @return [Boolean]
22
24
  class Configuration
25
+ DEFAULT_RESOURCE_CLIENT = 'rails-api'
26
+
23
27
  attr_accessor :profile, :required_aud, :resource_client,
24
- :env_claims_key, :suggest_in_logs
28
+ :suggest_in_logs
29
+ attr_reader :env_claims_key
25
30
 
26
31
  # Create a configuration with safe defaults.
27
32
  #
@@ -29,17 +34,122 @@ module Verikloak
29
34
  def initialize
30
35
  @profile = :strict_single
31
36
  @required_aud = []
32
- @resource_client = 'rails-api'
33
- @env_claims_key = 'verikloak.user'
37
+ @resource_client = DEFAULT_RESOURCE_CLIENT
38
+ self.env_claims_key = 'verikloak.user'
34
39
  @suggest_in_logs = true
35
40
  end
36
41
 
42
+ # Ensure `dup` produces an independent copy.
43
+ #
44
+ # @param source [Configuration]
45
+ # @return [void]
46
+ def initialize_copy(source)
47
+ super
48
+ @profile = safe_dup(source.profile)
49
+ @required_aud = duplicate_required_aud(source.required_aud)
50
+ @resource_client = safe_dup(source.resource_client)
51
+ self.env_claims_key = safe_dup(source.env_claims_key)
52
+ @suggest_in_logs = source.suggest_in_logs
53
+ end
54
+
37
55
  # Coerce `required_aud` into an array of strings.
38
56
  #
39
57
  # @return [Array<String>]
40
58
  def required_aud_list
41
59
  Array(required_aud).map(&:to_s)
42
60
  end
61
+
62
+ # @param value [#to_s, nil]
63
+ # @return [void]
64
+ def env_claims_key=(value)
65
+ @env_claims_key = value&.to_s
66
+ end
67
+
68
+ # Validate the configuration to ensure required values are present.
69
+ #
70
+ # @return [Configuration] the validated configuration
71
+ def validate!
72
+ audiences = required_aud_list
73
+ if audiences.empty?
74
+ raise Verikloak::Audience::ConfigurationError,
75
+ 'required_aud must include at least one audience'
76
+ end
77
+
78
+ profile_name = profile
79
+ profile_name = profile_name.to_sym if profile_name.respond_to?(:to_sym)
80
+ profile_name ||= :strict_single
81
+
82
+ ensure_resource_client!(audiences) if profile_name == :resource_or_aud
83
+
84
+ self
85
+ end
86
+
87
+ private
88
+
89
+ # Attempt to duplicate a value while tolerating non-duplicable inputs.
90
+ # Returns `nil` when given nil and falls back to the original on duplication errors.
91
+ #
92
+ # @param value [Object, nil]
93
+ # @return [Object, nil]
94
+ def safe_dup(value)
95
+ return if value.nil?
96
+
97
+ value.dup
98
+ rescue TypeError
99
+ value
100
+ end
101
+
102
+ # Build a deep-ish copy of `required_aud` so that mutations on copies
103
+ # do not leak back into the original configuration instance.
104
+ #
105
+ # @param value [Array<String,Symbol>, String, Symbol, nil]
106
+ # @return [Array<String,Symbol>, String, Symbol, nil]
107
+ def duplicate_required_aud(value)
108
+ return if value.nil?
109
+
110
+ return value.map { |item| safe_dup(item) } if value.is_a?(Array)
111
+
112
+ safe_dup(value)
113
+ end
114
+
115
+ # Ensure that the configured `resource_client` fits the `required_aud`
116
+ # list when the :resource_or_aud profile is active. Attempts to infer
117
+ # the client id from `required_aud` when possible and raises when
118
+ # ambiguity remains.
119
+ #
120
+ # @param audiences [Array<String>] coerced required audiences
121
+ # @return [void]
122
+ def ensure_resource_client!(audiences)
123
+ client = resource_client.to_s
124
+
125
+ needs_inference = needs_resource_client_inference?(client, audiences)
126
+
127
+ if needs_inference
128
+ if audiences.one?
129
+ self.resource_client = audiences.first
130
+ client = resource_client.to_s
131
+ else
132
+ raise Verikloak::Audience::ConfigurationError,
133
+ 'resource_client must match one of required_aud when using :resource_or_aud profile'
134
+ end
135
+ end
136
+
137
+ return if audiences.include?(client)
138
+
139
+ raise Verikloak::Audience::ConfigurationError,
140
+ 'resource_client must match one of required_aud when using :resource_or_aud profile'
141
+ end
142
+
143
+ # Decide whether the resource client should be inferred from the
144
+ # required audiences based on the current client value.
145
+ #
146
+ # @param client [String]
147
+ # @param audiences [Array<String>]
148
+ # @return [Boolean]
149
+ def needs_resource_client_inference?(client, audiences)
150
+ client.empty? ||
151
+ (client == DEFAULT_RESOURCE_CLIENT && !audiences.include?(client))
152
+ end
43
153
  end
44
154
  end
45
155
  end
@@ -23,12 +23,30 @@ module Verikloak
23
23
  end
24
24
  end
25
25
 
26
- # Raised when audience is insufficient for the configured profile.
26
+ # Raised when verified claims do not satisfy the configured profile.
27
+ # Typically emitted when the required audience list is empty or
28
+ # mismatches the token audiences.
27
29
  class Forbidden < Error
28
- # @param msg [String]
30
+ # Build a forbidden error with a customizable message while preserving
31
+ # the standard machine-friendly code and HTTP status.
32
+ #
33
+ # @param msg [String] alternate human-readable explanation
29
34
  def initialize(msg = 'insufficient audience')
30
35
  super(msg, code: 'insufficient_audience', http_status: 403)
31
36
  end
32
37
  end
38
+
39
+ # Raised when configuration is invalid.
40
+ # Used when runtime configuration checks detect missing or incompatible
41
+ # values before audience validation takes place.
42
+ class ConfigurationError < Error
43
+ # Build a configuration error while keeping a consistent error code
44
+ # and a 500 HTTP status to signal an internal misconfiguration.
45
+ #
46
+ # @param msg [String] alternate human-readable explanation
47
+ def initialize(msg = 'invalid audience configuration')
48
+ super(msg, code: 'audience_configuration_error', http_status: 500)
49
+ end
50
+ end
33
51
  end
34
52
  end
@@ -22,8 +22,9 @@ module Verikloak
22
22
  # @option opts [Boolean] :suggest_in_logs
23
23
  def initialize(app, **opts)
24
24
  @app = app
25
- @config = Verikloak::Audience.configure
25
+ @config = Verikloak::Audience.config.dup
26
26
  apply_overrides!(opts)
27
+ @config.validate!
27
28
  end
28
29
 
29
30
  # Evaluate the request against the audience profile.
@@ -31,13 +32,15 @@ module Verikloak
31
32
  # @param env [Hash] Rack environment
32
33
  # @return [Array(Integer, Hash, #each)] Rack response triple
33
34
  def call(env)
34
- claims = env[@config.env_claims_key] || {}
35
+ env_key = @config.env_claims_key
36
+ claims = env[env_key] || env[env_key&.to_sym] || {}
35
37
  return @app.call(env) if Checker.ok?(claims, @config)
36
38
 
37
39
  if @config.suggest_in_logs
38
40
  suggestion = Checker.suggest(claims, @config)
39
41
  aud_view = Array(claims['aud']).inspect
40
- warn("[verikloak-audience] insufficient_audience; suggestion profile=:#{suggestion} aud=#{aud_view}")
42
+ log_warning(env,
43
+ "[verikloak-audience] insufficient_audience; suggestion profile=:#{suggestion} aud=#{aud_view}")
41
44
  end
42
45
 
43
46
  body = { error: 'insufficient_audience',
@@ -50,12 +53,35 @@ module Verikloak
50
53
 
51
54
  # Apply provided options to the configuration instance.
52
55
  #
53
- # @param opts [Hash]
56
+ # @param opts [Hash] raw overrides provided to the middleware
54
57
  # @return [void]
55
58
  def apply_overrides!(opts)
56
59
  cfg = @config
60
+ opts.each_key do |key|
61
+ writer = "#{key}="
62
+ next if cfg.respond_to?(writer)
63
+
64
+ raise Verikloak::Audience::ConfigurationError,
65
+ "unknown middleware option :#{key}"
66
+ end
67
+
57
68
  opts.each do |k, v|
58
- cfg.public_send("#{k}=", v) if cfg.respond_to?("#{k}=")
69
+ cfg.public_send("#{k}=", v)
70
+ end
71
+ end
72
+
73
+ # Emit a warning for failed audience checks using request-scoped loggers
74
+ # when available.
75
+ #
76
+ # @param env [Hash] Rack environment
77
+ # @param message [String] warning payload
78
+ # @return [void]
79
+ def log_warning(env, message)
80
+ logger = env['verikloak.logger'] || env['rack.logger'] || env['action_dispatch.logger']
81
+ if logger.respond_to?(:warn)
82
+ logger.warn(message)
83
+ else
84
+ warn(message)
59
85
  end
60
86
  end
61
87
  end
@@ -33,6 +33,12 @@ module Verikloak
33
33
  self.class.insert_middleware(app)
34
34
  end
35
35
 
36
+ initializer 'verikloak_audience.configuration' do
37
+ config.after_initialize do
38
+ Verikloak::Audience.config.validate!
39
+ end
40
+ end
41
+
36
42
  # Performs the insertion into the middleware stack when the core
37
43
  # Verikloak middleware is available. Extracted for testability without
38
44
  # requiring a full Rails boot process.
@@ -4,6 +4,6 @@ module Verikloak
4
4
  module Audience
5
5
  # Current gem version.
6
6
  # @return [String]
7
- VERSION = '0.1.0'
7
+ VERSION = '0.2.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: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -35,20 +35,20 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: 0.1.2
38
+ version: 0.1.5
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: '0.2'
41
+ version: 1.0.0
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: 0.1.2
48
+ version: 0.1.5
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: '0.2'
51
+ version: 1.0.0
52
52
  description: |
53
53
  Rack middleware that enforces audience checks with deployable profiles,
54
54
  layering on top of Verikloak token verification.
@@ -74,7 +74,7 @@ metadata:
74
74
  source_code_uri: https://github.com/taiyaky/verikloak-audience
75
75
  changelog_uri: https://github.com/taiyaky/verikloak-audience/blob/main/CHANGELOG.md
76
76
  bug_tracker_uri: https://github.com/taiyaky/verikloak-audience/issues
77
- documentation_uri: https://rubydoc.info/gems/verikloak-audience/0.1.0
77
+ documentation_uri: https://rubydoc.info/gems/verikloak-audience/0.2.0
78
78
  rubygems_mfa_required: 'true'
79
79
  rdoc_options: []
80
80
  require_paths: