verikloak-bff 0.1.1 → 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: 19452cfc021fd5ffde923f55f294aafb99c4b80506047d0ef195233d019c5adb
4
- data.tar.gz: 3211ddb4779f6bbe1b079f6c83b018bb5b7b881bb267d253f94ffaaa20006711
3
+ metadata.gz: 91439a22efdb87206de07fbff74c284cba33a05cb1c7392070c0b9f7c837734f
4
+ data.tar.gz: be08bb99047e72502cad67dbea7c41b916aef6d49fb103e3dbf53ab79082f6db
5
5
  SHA512:
6
- metadata.gz: '0890fcfe4ac2af3ce8bdbc34ee0753744a3a56459a90da3441247a7d36779b28db68ae7998b349968f5ecfd937121d3fad96e2f21a092662da2cbedeb61a358c'
7
- data.tar.gz: 7a4e7888b2f2caafc0bd1345760d3ef5e049f860cc9f36dba028c2a2afebaf8625719b6b0dbad9f7a21c01db957368b5d10d73c3533f19ce8fdc9acb4513e3f0
6
+ metadata.gz: 135054bc3b1556597924c843a834d87c46c291da4d880ed63ef611cafb9705984694062f64d25a08f8d3f932f304cfb7277c4d25f25dfb3f35ad3d83f0768e47
7
+ data.tar.gz: 3358b1f9f3114e9a00a7d4edd283e5b2c81dd6166ec1a5718a581f5f83a7c1cf75d27f300da01d5dc25763f4569a50be2393203130c5a67adb00ffb1a42fc1d9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.0] - 2025-09-22
11
+
12
+ ### Added
13
+ - `Verikloak::HeaderSources` module for shared header normalization (consumable by verikloak-rails and other adapters).
14
+
15
+ ### Changed
16
+ - `Configuration#token_header_priority=` now normalizes and deduplicates entries, reusing the shared helper and ignoring `HTTP_AUTHORIZATION` automatically.
17
+ - `forwarded_header_name` assignments trigger re-normalization of token priority lists to keep middleware aligned across gems.
18
+
19
+ ## [0.1.2] - 2025-09-21
20
+
21
+ ### Added
22
+ - Configuration option `claims_consistency_mode` supporting `:log_only` so deployments can record mismatches without rejecting requests.
23
+
24
+ ### Changed
25
+ - Sanitize log payload strings (including JWT tags) before invoking hooks or emitting to loggers to mitigate log forging attempts.
26
+
27
+ ### Documentation
28
+ - Document trusted proxy hygiene, sanitized logging hooks, and the new log-only mode in the README and Rails guide.
29
+
10
30
  ## [0.1.1] - 2025-09-15
11
31
 
12
32
  ### Changed
data/README.md CHANGED
@@ -47,12 +47,13 @@ See `examples/rack.ru` for a tiny Rack app demo.
47
47
  | `require_forwarded_header` | Boolean | `false` | Reject when no `X-Forwarded-Access-Token` (blocks direct access). |
48
48
  | `enforce_header_consistency` | Boolean | `true` | If both headers exist, require identical token values. |
49
49
  | `enforce_claims_consistency` | Hash | `{}` | Mapping of header→claim to compare (e.g., `{ email: :email, user: :sub, groups: :realm_roles }`). |
50
+ | `claims_consistency_mode` | Symbol (`:enforce`/`:log_only`) | `:enforce` | When `:log_only`, mismatches are logged but the request continues (still require downstream JWT verification). |
50
51
  | `strip_suspicious_headers` | Boolean | `true` | Remove external `X-Auth-Request-*` before passing downstream. |
51
52
  | `xff_strategy` | Symbol (`:rightmost`/`:leftmost`) | `:rightmost` | Which peer to pick from `X-Forwarded-For`. |
52
53
  | `peer_preference` | Symbol (`:remote_then_xff`/`:xff_only`) | `:remote_then_xff` | Whether to prefer `REMOTE_ADDR` before falling back to XFF. |
53
54
  | `clock_skew_leeway` | Integer (seconds) | `30` | Reserved for small exp/nbf skew handled by core verifier. |
54
55
  | `logger` | `Logger` or `nil` | `nil` | Logger for audit tags (`rid`, `sub`, `kid`, `iss/aud`). |
55
- | `token_header_priority` | Array[String] | `['HTTP_X_FORWARDED_ACCESS_TOKEN']` | When Authorization is empty and no token chosen, seed it from these env headers in order. `HTTP_AUTHORIZATION` is ignored as a source; forwarded header is considered only from trusted peers. |
56
+ | `token_header_priority` | Array[String] | `['HTTP_X_FORWARDED_ACCESS_TOKEN']` | When Authorization is empty and no token chosen, seed it from these env headers in order. Values are normalized via `Verikloak::HeaderSources`; `HTTP_AUTHORIZATION` is ignored as a source. |
56
57
  | `forwarded_header_name` | String | `HTTP_X_FORWARDED_ACCESS_TOKEN` | Env key for forwarded access token. |
57
58
  | `auth_request_headers` | Hash | see code | Mapping for `X-Auth-Request-*` env keys: `{ email, user, groups }`. |
58
59
 
@@ -69,6 +70,9 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
69
70
  - Set `peer_preference: :remote_then_xff` (default) to evaluate trust using the direct peer first, then fall back to the nearest (rightmost) `X-Forwarded-For` value.
70
71
  - If you run only behind a single, known proxy chain and want to rely solely on XFF ordering, use `peer_preference: :xff_only` and control position with `xff_strategy`.
71
72
 
73
+ - Trusted proxy hygiene
74
+ - Keep `trusted_proxies` as specific as possible (individual IPs, tight CIDR ranges, or regexes). Review the list whenever proxy topology changes to avoid unintentionally widening the trust boundary.
75
+
72
76
  - Header name customization
73
77
  - Forwarded-access-token header can be changed via:
74
78
  - `forwarded_header_name: 'HTTP_X_CUSTOM_FORWARD_TOKEN'`
@@ -78,12 +82,16 @@ For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/r
78
82
  - Authorization seeding from priority headers
79
83
  - When no token is chosen and `HTTP_AUTHORIZATION` is empty, the middleware consults `token_header_priority` to seed Authorization.
80
84
  - `HTTP_AUTHORIZATION` itself is never used as a source; forwarded headers are considered only from trusted peers.
85
+ - Other gems can `require 'verikloak/header_sources'` to reuse the same normalization helpers when sharing configuration defaults.
81
86
 
82
87
  - Observability helpers
83
88
  - Downstream can inspect `env['verikloak.bff.token']` (chosen token, unverified) and `env['verikloak.bff.selected_peer']` (peer IP selected for trust decisions).
84
- - Provide a structured log hook with `log_with: ->(payload) { logger.info(payload.to_json) }` to consume the same fields emitted to `logger`.
89
+ - Provide a structured log hook with `log_with: ->(payload) { logger.info(payload.to_json) }` to consume the same fields emitted to `logger`. Payload strings are sanitized (control characters removed) before hooks and loggers run to mitigate log forging.
85
90
  - Caution: avoid logging the entire Rack `env` in application logs. Treat `env['verikloak.bff.token']` as sensitive; never emit raw tokens or PII (e.g., emails) to logs.
86
91
 
92
+ - Claims consistency modes
93
+ - Default `:enforce` mode rejects requests with mismatches. Switch to `claims_consistency_mode: :log_only` when you only need observability signals; downstream services must continue verifying JWT signatures, issuer, audience, and expirations.
94
+
87
95
  ## Development (for contributors)
88
96
  Clone and install dependencies:
89
97
 
@@ -22,6 +22,8 @@
22
22
  # @!attribute [rw] logger
23
23
  # @return [Logger, nil] optional logger for audit tags
24
24
 
25
+ require 'verikloak/header_sources'
26
+
25
27
  module Verikloak
26
28
  module BFF
27
29
  # Configuration for Verikloak::BFF middleware (trusted proxies, header policies, logging, etc.).
@@ -29,24 +31,31 @@ module Verikloak
29
31
  attr_accessor :trusted_proxies, :prefer_forwarded, :require_forwarded_header,
30
32
  :enforce_header_consistency, :enforce_claims_consistency,
31
33
  :strip_suspicious_headers, :xff_strategy, :clock_skew_leeway,
32
- :logger, :token_header_priority, :peer_preference,
33
- :forwarded_header_name, :auth_request_headers, :log_with
34
+ :logger, :peer_preference, :auth_request_headers, :log_with,
35
+ :claims_consistency_mode
36
+ attr_reader :token_header_priority, :forwarded_header_name
34
37
 
35
38
  # enforce_claims_consistency example:
36
39
  # { email: :email, user: :sub, groups: :realm_roles }
40
+ #
41
+ # Initialize configuration with secure defaults for proxy trust, token
42
+ # handling, and logging behavior.
43
+ #
44
+ # @return [void]
37
45
  def initialize
38
46
  @trusted_proxies = []
39
47
  @prefer_forwarded = true
40
48
  @require_forwarded_header = false
41
49
  @enforce_header_consistency = true
42
50
  @enforce_claims_consistency = {}
51
+ @claims_consistency_mode = :enforce
43
52
  @strip_suspicious_headers = true
44
53
  @xff_strategy = :rightmost
45
54
  @peer_preference = :remote_then_xff
46
55
  @clock_skew_leeway = 30
47
56
  @logger = nil
48
57
  @log_with = nil
49
- @forwarded_header_name = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
58
+ self.forwarded_header_name = Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER
50
59
  @auth_request_headers = {
51
60
  email: 'HTTP_X_AUTH_REQUEST_EMAIL',
52
61
  user: 'HTTP_X_AUTH_REQUEST_USER',
@@ -54,9 +63,37 @@ module Verikloak
54
63
  }
55
64
  # When Authorization is empty and no chosen token exists, try these env headers (in order)
56
65
  # to seed Authorization, similar to verikloak-rails behavior. HTTP_AUTHORIZATION is always ignored as a source.
57
- @token_header_priority = %w[
58
- HTTP_X_FORWARDED_ACCESS_TOKEN
59
- ]
66
+ self.token_header_priority = Verikloak::HeaderSources.default_priority(forwarded_header: @forwarded_header_name)
67
+ end
68
+
69
+ # Override forwarded header name while re-normalizing the token priority list.
70
+ # When the forwarded header changes, downstream priority normalization must
71
+ # be refreshed because the forwarded value participates in that list.
72
+ #
73
+ # @param header [String, Symbol]
74
+ # @return [void]
75
+ def forwarded_header_name=(header)
76
+ @forwarded_header_name = Verikloak::HeaderSources.normalize_env_key(header)
77
+ renormalize_token_priority!
78
+ end
79
+
80
+ # Assign token header priority list using shared normalization logic.
81
+ #
82
+ # @param priority [Array<String, Symbol>, String, Symbol, nil]
83
+ # @return [void]
84
+ def token_header_priority=(priority)
85
+ normalized, = Verikloak::HeaderSources.normalize_priority(priority, forwarded_header: @forwarded_header_name)
86
+ @token_header_priority = normalized
87
+ end
88
+
89
+ private
90
+
91
+ # Re-apply token header normalization so it reflects the current forwarded header.
92
+ #
93
+ # @return [void]
94
+ def renormalize_token_priority!
95
+ # Refresh the normalized list so it reflects the new forwarded header name.
96
+ self.token_header_priority = @token_header_priority
60
97
  end
61
98
  end
62
99
  end
@@ -15,12 +15,11 @@ module Verikloak
15
15
  module ConsistencyChecks
16
16
  module_function
17
17
 
18
- # Parse JWT payload without verifying (verikloak core will verify later).
19
- # Decode JWT payload without verification.
18
+ # Decode the JWT payload without verifying the signature. Intended only
19
+ # for lightweight claim comparisons; full verification occurs downstream.
20
20
  #
21
21
  # @param token [String, nil]
22
22
  # @return [Hash] claims or empty hash on error
23
-
24
23
  def decode_claims(token)
25
24
  return {} unless token
26
25
  return {} if token.bytesize > Constants::MAX_TOKEN_BYTES
@@ -36,6 +35,7 @@ module Verikloak
36
35
  # @param env [Hash]
37
36
  # @param token [String, nil]
38
37
  # @param mapping [Hash] e.g., { email: :email, user: :sub, groups: :realm_roles }
38
+ # @param headers_map [Hash{Symbol=>String}, nil] overrides for header keys
39
39
  # @return [true, Array(:error, Symbol)] true or error tuple with failing field
40
40
  def enforce!(env, token, mapping, headers_map = nil)
41
41
  return true if mapping.nil? || mapping.empty?
@@ -63,6 +63,7 @@ module Verikloak
63
63
  #
64
64
  # @param env [Hash]
65
65
  # @param key [Symbol]
66
+ # @param headers_map [Hash{Symbol=>String}, nil]
66
67
  # @return [String, nil]
67
68
  def extract_header_value(env, key, headers_map = nil)
68
69
  return env[headers_map[key]] if headers_map && headers_map[key]
@@ -12,6 +12,12 @@ module Verikloak
12
12
  class Error < StandardError
13
13
  attr_reader :code, :http_status
14
14
 
15
+ # Build a BFF error with a stable code and HTTP status.
16
+ #
17
+ # @param message [String, nil]
18
+ # @param code [String]
19
+ # @param http_status [Integer]
20
+ # @return [void]
15
21
  def initialize(message = nil, code: 'bff_error', http_status: 401)
16
22
  super(message || code)
17
23
  @code = code
@@ -21,6 +27,8 @@ module Verikloak
21
27
 
22
28
  # Raised when a request did not pass through a trusted proxy peer.
23
29
  class UntrustedProxyError < Error
30
+ # @param msg [String]
31
+ # @return [void]
24
32
  def initialize(msg = 'request did not pass through a trusted proxy')
25
33
  super(msg, code: 'untrusted_proxy', http_status: 401)
26
34
  end
@@ -28,6 +36,8 @@ module Verikloak
28
36
 
29
37
  # Raised when require_forwarded_header is enabled but the forwarded token is absent.
30
38
  class MissingForwardedTokenError < Error
39
+ # @param msg [String]
40
+ # @return [void]
31
41
  def initialize(msg = 'missing X-Forwarded-Access-Token')
32
42
  super(msg, code: 'missing_forwarded_token', http_status: 401)
33
43
  end
@@ -35,6 +45,8 @@ module Verikloak
35
45
 
36
46
  # Raised when Authorization and X-Forwarded-Access-Token both exist and differ.
37
47
  class HeaderMismatchError < Error
48
+ # @param msg [String]
49
+ # @return [void]
38
50
  def initialize(msg = 'authorization and forwarded token mismatch')
39
51
  super(msg, code: 'header_mismatch', http_status: 401)
40
52
  end
@@ -42,6 +54,9 @@ module Verikloak
42
54
 
43
55
  # Raised when X-Auth-Request-* headers conflict with JWT claims.
44
56
  class ClaimsMismatchError < Error
57
+ # @param field [Symbol, String]
58
+ # @param msg [String, nil]
59
+ # @return [void]
45
60
  def initialize(field, msg = nil)
46
61
  super(msg || "claims/header mismatch for #{field}", code: 'claims_mismatch', http_status: 403)
47
62
  end
@@ -16,6 +16,8 @@ module Verikloak
16
16
  # Extract normalized tokens from the Rack env.
17
17
  #
18
18
  # @param env [Hash]
19
+ # @param forwarded_header_name [String] Rack env key for forwarded header
20
+ # @param auth_header_name [String] Rack env key for Authorization header
19
21
  # @return [Array(String, String)] [auth_token, forwarded_token]
20
22
  def extract(env, forwarded_header_name = FORWARDED_HEADER, auth_header_name = AUTH_HEADER)
21
23
  fwd_raw = env[forwarded_header_name]
@@ -54,6 +56,7 @@ module Verikloak
54
56
  # - Detects scheme case-insensitively
55
57
  # - Inserts a missing space (e.g., 'BearerXYZ' => 'Bearer XYZ')
56
58
  # - Collapses multiple spaces/tabs after the scheme to a single space
59
+ #
57
60
  # @param token [String]
58
61
  # @return [String]
59
62
  def ensure_bearer(token)
@@ -78,6 +81,7 @@ module Verikloak
78
81
  #
79
82
  # @param env [Hash]
80
83
  # @param token [String]
84
+ # @return [void]
81
85
  def set_authorization!(env, token)
82
86
  existing = env[AUTH_HEADER].to_s
83
87
  # Overwrite only if Authorization is empty or not a valid Bearer value
@@ -98,6 +102,8 @@ module Verikloak
98
102
  # downstream when not emitted by a trusted proxy.
99
103
  #
100
104
  # @param env [Hash]
105
+ # @param headers [Hash{Symbol=>String}, nil] explicit headers to strip
106
+ # @return [void]
101
107
  def strip_suspicious!(env, headers = nil)
102
108
  if headers.is_a?(Hash)
103
109
  headers.each_value { |h| env.delete(h) }
@@ -15,6 +15,7 @@ require 'rack/utils'
15
15
  require 'json'
16
16
  require 'jwt'
17
17
  require 'digest'
18
+ require 'verikloak/header_sources'
18
19
  require 'verikloak/bff/configuration'
19
20
  require 'verikloak/bff/errors'
20
21
  require 'verikloak/bff/proxy_trust'
@@ -24,6 +25,83 @@ require 'verikloak/bff/constants'
24
25
 
25
26
  module Verikloak
26
27
  module BFF
28
+ # Internal helpers that sanitize tokens and log payloads for HeaderGuard.
29
+ module HeaderGuardSanitizer
30
+ LOG_CONTROL_CHARS = /[[:cntrl:]]/
31
+
32
+ module_function
33
+
34
+ # Generate sanitized token metadata suitable for structured logging without
35
+ # verifying the signature.
36
+ #
37
+ # @param token [String, nil]
38
+ # @return [Hash{Symbol=>Object}] sanitized tags keyed by JWT claim/header
39
+ def token_tags(token)
40
+ return {} unless token
41
+
42
+ payload, header = decode_unverified(token)
43
+ aud = payload['aud']
44
+ aud = aud.join(' ') if aud.is_a?(Array)
45
+ {
46
+ sub: sanitize_log_field(payload['sub']&.to_s),
47
+ iss: sanitize_log_field(payload['iss']&.to_s),
48
+ aud: sanitize_log_field(aud&.to_s),
49
+ kid: sanitize_log_field(header['kid']&.to_s)
50
+ }.compact
51
+ rescue StandardError
52
+ {}
53
+ end
54
+
55
+ # Decode a JWT without verifying the signature while guarding against
56
+ # excessively large tokens.
57
+ #
58
+ # @param token [String, nil]
59
+ # @return [Array<Hash>] payload and header hashes
60
+ def decode_unverified(token)
61
+ return [{}, {}] if token.nil? || token.bytesize > Constants::MAX_TOKEN_BYTES
62
+
63
+ JWT.decode(token, nil, false)
64
+ rescue StandardError
65
+ [{}, {}]
66
+ end
67
+
68
+ # Remove unsafe characters from a structured logging payload.
69
+ #
70
+ # @param payload [Hash]
71
+ # @return [Hash] sanitized payload suitable for logging
72
+ def sanitize_payload(payload)
73
+ payload.transform_values { |value| sanitize_log_field(value) }.compact
74
+ end
75
+
76
+ # Sanitize an individual value destined for logs, pruning empty results.
77
+ #
78
+ # @param value [Object]
79
+ # @return [Object, nil] sanitized value or nil when the result is empty
80
+ def sanitize_log_field(value)
81
+ case value
82
+ when nil
83
+ nil
84
+ when String
85
+ sanitized = sanitize_string(value)
86
+ sanitized.empty? ? nil : sanitized
87
+ when Array
88
+ sanitized = value.map { |item| item.is_a?(String) ? sanitize_string(item) : item }
89
+ sanitized.reject! { |item| item.nil? || (item.is_a?(String) && item.empty?) }
90
+ sanitized.empty? ? nil : sanitized
91
+ else
92
+ value
93
+ end
94
+ end
95
+
96
+ # Remove control characters and invalid UTF-8 from a string.
97
+ #
98
+ # @param value [#to_s]
99
+ # @return [String]
100
+ def sanitize_string(value)
101
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').gsub(LOG_CONTROL_CHARS, '')
102
+ end
103
+ end
104
+
27
105
  # Rack middleware that enforces BFF boundary and header/claims consistency.
28
106
  class HeaderGuard
29
107
  # Accept both Rack 2 and Rack 3 builder call styles:
@@ -73,6 +151,7 @@ module Verikloak
73
151
  # Apply per-instance configuration overrides.
74
152
  #
75
153
  # @param opts [Hash]
154
+ # @return [void]
76
155
  def apply_overrides!(opts)
77
156
  cfg = @config
78
157
  opts.each do |k, v|
@@ -106,37 +185,6 @@ module Verikloak
106
185
  end
107
186
  end
108
187
 
109
- # Extract selected JWT tags for audit logging (best-effort, unverified).
110
- #
111
- # @param token [String, nil]
112
- # @return [Hash] subset of {sub, iss, aud, kid}
113
- def token_tags(token)
114
- return {} unless token
115
-
116
- payload, header = decode_unverified(token)
117
- {
118
- sub: payload['sub'],
119
- iss: payload['iss'],
120
- aud: (payload['aud'].is_a?(Array) ? payload['aud'].join(' ') : payload['aud']).to_s,
121
- kid: header['kid']
122
- }.compact
123
- rescue StandardError
124
- {}
125
- end
126
-
127
- # Decode JWT header/payload without validation (for logging only).
128
- #
129
- # @param token [String]
130
- # @return [Array(Hash, Hash)] [payload, header]
131
-
132
- def decode_unverified(token)
133
- return [{}, {}] if token.nil? || token.bytesize > Constants::MAX_TOKEN_BYTES
134
-
135
- JWT.decode(token, nil, false)
136
- rescue StandardError
137
- [{}, {}]
138
- end
139
-
140
188
  # Extract request id for logging from common headers.
141
189
  #
142
190
  # @param env [Hash]
@@ -158,19 +206,21 @@ module Verikloak
158
206
  # @param env [Hash]
159
207
  # @param kind [Symbol] :ok, :mismatch, :claims_mismatch, :error
160
208
  # @param attrs [Hash]
209
+ # @return [void]
161
210
  def log_event(env, kind, **attrs)
162
211
  lg = logger(env)
163
212
  payload = { event: 'bff.header_guard', kind: kind, rid: request_id(env) }.merge(attrs).compact
213
+ sanitized = HeaderGuardSanitizer.sanitize_payload(payload)
164
214
  if @config.log_with.respond_to?(:call)
165
215
  begin
166
- @config.log_with.call(payload)
216
+ @config.log_with.call(sanitized)
167
217
  rescue StandardError
168
218
  # ignore log hook failures
169
219
  end
170
220
  end
171
221
  return unless lg
172
222
 
173
- msg = payload.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
223
+ msg = sanitized.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
174
224
  level = (kind == :ok ? :info : :warn)
175
225
  lg.public_send(level, msg)
176
226
  rescue StandardError
@@ -180,6 +230,7 @@ module Verikloak
180
230
  # Raise when the request did not come through a trusted proxy.
181
231
  #
182
232
  # @param env [Hash]
233
+ # @raise [UntrustedProxyError]
183
234
  def ensure_trusted_proxy!(env)
184
235
  return if ProxyTrust.trusted?(env, @config.trusted_proxies, @config.xff_strategy)
185
236
 
@@ -189,6 +240,7 @@ module Verikloak
189
240
  # Enforce presence of forwarded token when required.
190
241
  #
191
242
  # @param fwd_token [String, nil]
243
+ # @raise [MissingForwardedTokenError]
192
244
  def ensure_forwarded_if_required!(fwd_token)
193
245
  return unless @config.require_forwarded_header
194
246
  raise MissingForwardedTokenError if fwd_token.nil? || fwd_token.to_s.strip.empty?
@@ -199,6 +251,7 @@ module Verikloak
199
251
  # @param env [Hash]
200
252
  # @param auth_token [String, nil]
201
253
  # @param fwd_token [String, nil]
254
+ # @raise [HeaderMismatchError]
202
255
  def enforce_header_consistency!(env, auth_token, fwd_token)
203
256
  return unless @config.enforce_header_consistency
204
257
  return unless auth_token && fwd_token
@@ -215,26 +268,41 @@ module Verikloak
215
268
  #
216
269
  # @param env [Hash]
217
270
  # @param chosen [String, nil]
271
+ # @return [void]
272
+ # @raise [ClaimsMismatchError]
218
273
  def enforce_claims_consistency!(env, chosen)
219
274
  res = ConsistencyChecks.enforce!(env, chosen, @config.enforce_claims_consistency, @config.auth_request_headers)
220
275
  return unless res.is_a?(Array) && res.first == :error
221
276
 
222
277
  field = res.last
223
278
  log_event(env, :claims_mismatch, field: field.to_s)
279
+ return if claims_consistency_log_only?
280
+
224
281
  raise ClaimsMismatchError, field
225
282
  end
226
283
 
284
+ # Determine whether claims mismatches should only be logged.
285
+ #
286
+ # @return [Boolean]
287
+ def claims_consistency_log_only?
288
+ mode = @config.claims_consistency_mode || :enforce
289
+ mode = mode.to_sym if mode.is_a?(String)
290
+ mode = :enforce unless %i[enforce log_only].include?(mode)
291
+ mode == :log_only
292
+ end
293
+
227
294
  # Set normalized Authorization header and emit success log.
228
295
  #
229
296
  # @param env [Hash]
230
297
  # @param chosen [String, nil]
231
298
  # @param auth_token [String, nil]
232
299
  # @param fwd_token [String, nil]
300
+ # @return [void]
233
301
  def normalize_authorization!(env, chosen, auth_token, fwd_token)
234
302
  return unless chosen
235
303
 
236
304
  ForwardedToken.set_authorization!(env, chosen)
237
- log_event(env, :ok, source: token_source(auth_token, fwd_token), **token_tags(chosen))
305
+ log_event(env, :ok, source: token_source(auth_token, fwd_token), **HeaderGuardSanitizer.token_tags(chosen))
238
306
  end
239
307
 
240
308
  # Build a minimal RFC6750-style error response.
@@ -252,19 +320,21 @@ module Verikloak
252
320
 
253
321
  # Resolve the first env header from which to source a bearer token.
254
322
  # Forwarded is considered only when the peer is trusted; HTTP_AUTHORIZATION is never a source.
323
+ #
255
324
  # @param env [Hash]
256
325
  # @return [String, nil]
257
326
  def resolve_first_token_header(env)
258
327
  candidates = Array(@config.token_header_priority).dup
259
- candidates -= ['HTTP_AUTHORIZATION']
260
- fwd_key = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
328
+ candidates -= [Verikloak::HeaderSources::AUTHORIZATION_HEADER]
329
+ fwd_key = @config.forwarded_header_name || Verikloak::HeaderSources::DEFAULT_FORWARDED_HEADER
261
330
  if candidates.include?(fwd_key) && !ProxyTrust.from_trusted_proxy?(env, @config.trusted_proxies)
262
331
  candidates -= [fwd_key]
263
332
  end
264
333
  candidates.find { |k| (v = env[k]) && !v.to_s.empty? }
265
334
  end
266
335
 
267
- # Seed Authorization from priority headers if nothing chosen and empty Authorization
336
+ # Seed Authorization from priority headers if nothing chosen and empty Authorization.
337
+ #
268
338
  # @param env [Hash]
269
339
  # @param chosen [String, nil]
270
340
  # @return [String, nil] possibly updated chosen token
@@ -280,9 +350,11 @@ module Verikloak
280
350
  chosen
281
351
  end
282
352
 
283
- # Expose hints to downstream
353
+ # Expose hints to downstream middleware or apps.
354
+ #
284
355
  # @param env [Hash]
285
356
  # @param chosen [String, nil]
357
+ # @return [void]
286
358
  def expose_env_hints(env, chosen)
287
359
  env['verikloak.bff.token'] = chosen if chosen
288
360
  env['verikloak.bff.selected_peer'] =
@@ -53,6 +53,7 @@ module Verikloak
53
53
  end
54
54
 
55
55
  # Return the selected peer IP according to preference and strategy.
56
+ #
56
57
  # @param env [Hash]
57
58
  # @param preference [Symbol] :remote_then_xff or :xff_only
58
59
  # @param strategy [Symbol] :rightmost or :leftmost
@@ -70,6 +71,7 @@ module Verikloak
70
71
  end
71
72
 
72
73
  # Parse string to IPAddr or nil on failure.
74
+ #
73
75
  # @param str [String]
74
76
  # @return [IPAddr, nil]
75
77
  def ip_or_nil(str)
@@ -79,6 +81,7 @@ module Verikloak
79
81
  end
80
82
 
81
83
  # Check whether a single rule trusts the selected remote.
84
+ #
82
85
  # @param rule [String, Regexp, Proc]
83
86
  # @param remote [String]
84
87
  # @param remote_ip [IPAddr, nil]
@@ -104,6 +107,7 @@ module Verikloak
104
107
 
105
108
  # Determine if the request originates from a trusted proxy subnet.
106
109
  # Rails-aligned behavior: prefer REMOTE_ADDR, fallback to nearest (rightmost) X-Forwarded-For.
110
+ #
107
111
  # @param env [Hash]
108
112
  # @param trusted [Array<String, Regexp, Proc>, nil]
109
113
  # @return [Boolean]
@@ -5,6 +5,6 @@
5
5
  # @return [String]
6
6
  module Verikloak
7
7
  module BFF
8
- VERSION = '0.1.1'
8
+ VERSION = '0.2.0'
9
9
  end
10
10
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Helpers shared across verikloak middleware for normalizing Rack env header
4
+ # names and token source priority lists. Extracted to allow other gems (such as
5
+ # verikloak-rails) to consume the same normalization logic.
6
+ module Verikloak
7
+ # Provides normalization helpers for Rack env header keys and token priority lists.
8
+ module HeaderSources
9
+ module_function
10
+
11
+ DEFAULT_FORWARDED_HEADER = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
12
+ AUTHORIZATION_HEADER = 'HTTP_AUTHORIZATION'
13
+
14
+ # Normalize a Rack env header key, accepting symbols, mixed case, or dash
15
+ # separated names and returning an upper-case HTTP_* variant.
16
+ #
17
+ # @param header [String, Symbol, nil]
18
+ # @return [String] normalized env key or empty string when blank
19
+ def normalize_env_key(header)
20
+ key = header.to_s.strip
21
+ return '' if key.empty?
22
+
23
+ key = key.tr('-', '_').upcase
24
+ key = "HTTP_#{key}" unless key.start_with?('HTTP_')
25
+ key
26
+ end
27
+
28
+ # Normalize a token priority list by stripping blanks, rejecting
29
+ # Authorization as a source, and deduplicating entries while preserving
30
+ # order.
31
+ #
32
+ # @param priority [Array<String, Symbol>, String, Symbol, nil]
33
+ # @param forwarded_header [String, Symbol]
34
+ # @param drop_authorization [Boolean]
35
+ # @return [Array(Array<String>, String)] normalized priority and forwarded key
36
+ def normalize_priority(priority, forwarded_header: DEFAULT_FORWARDED_HEADER, drop_authorization: true)
37
+ forwarded_env = normalize_env_key(forwarded_header)
38
+ items = Array(priority).flatten
39
+
40
+ normalized = items.map { |value| normalize_env_key(value) }.reject(&:empty?)
41
+ normalized = [forwarded_env] if normalized.empty?
42
+ normalized = normalized.reject { |key| drop_authorization && key == AUTHORIZATION_HEADER }
43
+
44
+ deduped = []
45
+ normalized.each do |key|
46
+ deduped << key unless deduped.include?(key)
47
+ end
48
+
49
+ [deduped.freeze, forwarded_env]
50
+ end
51
+
52
+ # Default priority list using the provided forwarded header name.
53
+ #
54
+ # @param forwarded_header [String, Symbol]
55
+ # @return [Array<String>] normalized default priority
56
+ def default_priority(forwarded_header: DEFAULT_FORWARDED_HEADER)
57
+ normalize_priority([forwarded_header], forwarded_header: forwarded_header).first
58
+ end
59
+ end
60
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: verikloak-bff
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - taiyaky
@@ -55,20 +55,20 @@ dependencies:
55
55
  requirements:
56
56
  - - ">="
57
57
  - !ruby/object:Gem::Version
58
- version: 0.1.2
58
+ version: 0.1.5
59
59
  - - "<"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.2'
61
+ version: 1.0.0
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: 0.1.2
68
+ version: 0.1.5
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
- version: '0.2'
71
+ version: 1.0.0
72
72
  description: Framework-agnostic Rack middleware that normalizes forwarded tokens,
73
73
  enforces trust boundaries, and checks header/claims consistency before verikloak.
74
74
  executables: []
@@ -88,6 +88,7 @@ files:
88
88
  - lib/verikloak/bff/header_guard.rb
89
89
  - lib/verikloak/bff/proxy_trust.rb
90
90
  - lib/verikloak/bff/version.rb
91
+ - lib/verikloak/header_sources.rb
91
92
  homepage: https://github.com/taiyaky/verikloak-bff
92
93
  licenses:
93
94
  - MIT
@@ -95,7 +96,7 @@ metadata:
95
96
  source_code_uri: https://github.com/taiyaky/verikloak-bff
96
97
  changelog_uri: https://github.com/taiyaky/verikloak-bff/blob/main/CHANGELOG.md
97
98
  bug_tracker_uri: https://github.com/taiyaky/verikloak-bff/issues
98
- documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.1.1
99
+ documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.2.0
99
100
  rubygems_mfa_required: 'true'
100
101
  rdoc_options: []
101
102
  require_paths: