verikloak 0.1.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.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ # Base error class for all Verikloak-related exceptions.
5
+ #
6
+ # All errors raised by this library inherit from this class so they can be
7
+ # rescued in a consistent way. Each error may carry a short, programmatic
8
+ # `code` (e.g., "invalid_token", "jwks_fetch_failed") that middleware and
9
+ # callers can use to map to HTTP statuses or telemetry.
10
+ #
11
+ # @attr_reader [String, Symbol, nil] code
12
+ # A short error code identifier suitable for programmatic handling.
13
+ #
14
+ # @example Raising with a code
15
+ # raise Verikloak::Error.new("Something went wrong", code: "internal_error")
16
+ class Error < StandardError
17
+ attr_reader :code
18
+
19
+ # @param message [String, nil] Human-readable error message.
20
+ # @param code [String, Symbol, nil] Optional short error code for programmatic handling.
21
+ def initialize(message = nil, code: nil)
22
+ super(message)
23
+ @code = code
24
+ end
25
+ end
26
+
27
+ # Raised when discovery document fetching or validation fails.
28
+ #
29
+ # Typical causes include network failures, non-200 responses, invalid JSON,
30
+ # missing required fields (e.g., `jwks_uri`, `issuer`), or redirect issues.
31
+ #
32
+ # @see Verikloak::Discovery
33
+ # @raise [DiscoveryError] from {Verikloak::Discovery#fetch!}
34
+ class DiscoveryError < Error; end
35
+
36
+ # Raised for middleware-level failures while processing a Rack request.
37
+ #
38
+ # Examples include missing/invalid Authorization headers, JWKS cache
39
+ # initialization failures, or infrastructure issues detected by the
40
+ # middleware itself.
41
+ #
42
+ # @see Verikloak::Middleware
43
+ class MiddlewareError < Error; end
44
+
45
+ # Raised when JWT token verification fails or the token is invalid.
46
+ #
47
+ # Common causes:
48
+ # - Invalid or unsupported algorithm
49
+ # - Invalid signature
50
+ # - Expired (`exp`) or not-yet-valid (`nbf`) token
51
+ # - Invalid `iss` / `aud` claims
52
+ # - Malformed token structure or decode failures
53
+ #
54
+ # @see Verikloak::TokenDecoder
55
+ # @raise [TokenDecoderError] from {Verikloak::TokenDecoder#decode!}
56
+ class TokenDecoderError < Error; end
57
+
58
+ # Raised when JWKS fetching, validation, or cache handling fails.
59
+ #
60
+ # Causes include HTTP failures, invalid JSON, missing required JWK fields,
61
+ # or receiving 304 Not Modified without a prior cached value.
62
+ #
63
+ # @see Verikloak::JwksCache
64
+ # @raise [JwksCacheError] from {Verikloak::JwksCache#fetch!}
65
+ class JwksCacheError < Error; end
66
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module Verikloak
7
+ # Caches and revalidates JSON Web Key Sets (JWKS) fetched from a remote endpoint.
8
+ #
9
+ # This cache supports two HTTP cache mechanisms:
10
+ # - **ETag revalidation** via `If-None-Match` → returns `304 Not Modified` when unchanged.
11
+ # - **TTL freshness** via `Cache-Control: max-age` → avoids HTTP requests while fresh.
12
+ #
13
+ # On a successful `200 OK`, the cache:
14
+ # - Parses the JWKS JSON (`{"keys":[...]}`) and validates each JWK has `kid`, `kty`, `n`, `e`.
15
+ # - Stores the keys in-memory, records `ETag`, and computes freshness from `Cache-Control`.
16
+ #
17
+ # On a `304 Not Modified`, the cache:
18
+ # - Keeps existing keys and ETag, optionally updates TTL from new `Cache-Control`, and refreshes `fetched_at`.
19
+ #
20
+ # Errors are raised as {Verikloak::JwksCacheError} with structured `code` values:
21
+ # - `jwks_fetch_failed` (network/HTTP errors)
22
+ # - `jwks_parse_failed` (invalid JSON / structure)
23
+ # - `jwks_cache_miss` (304 received but nothing cached)
24
+ #
25
+ # @example Basic usage
26
+ # cache = Verikloak::JwksCache.new(jwks_uri: "https://issuer.example.com/protocol/openid-connect/certs")
27
+ # keys = cache.fetch! # → Array&lt;Hash&gt; of JWKs
28
+ #
29
+ # @see #fetch!
30
+ # @see #cached
31
+ class JwksCache
32
+ # @param jwks_uri [String] HTTPS URL of the JWKS endpoint
33
+ # @raise [JwksCacheError] if the URI is not an HTTP(S) URL
34
+ def initialize(jwks_uri:)
35
+ unless jwks_uri.is_a?(String) && jwks_uri.strip.match?(%r{^https?://})
36
+ raise JwksCacheError.new('Invalid JWKS URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
37
+ end
38
+
39
+ @jwks_uri = jwks_uri
40
+ @cached_keys = nil
41
+ @etag = nil
42
+ @fetched_at = nil
43
+ @max_age = nil
44
+ end
45
+
46
+ # Fetches the JWKS and updates the in-memory cache.
47
+ #
48
+ # Performs an HTTP GET with `If-None-Match` when an ETag is present and handles:
49
+ # - 200: parses/validates body, updates keys, ETag, TTL and `fetched_at`.
50
+ # - 304: keeps cached keys, updates TTL from headers (if present), refreshes `fetched_at`.
51
+ #
52
+ # @return [Array&lt;Hash&gt;] the cached JWKs after fetch/revalidation
53
+ # @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
54
+ def fetch!
55
+ with_error_handling do
56
+ # Build conditional request headers (ETag-based)
57
+ headers = build_conditional_headers
58
+ # Perform HTTP GET request
59
+ response = Faraday.get(@jwks_uri, nil, headers)
60
+ # Handle HTTP response according to status code
61
+ handle_response(response)
62
+ end
63
+ end
64
+
65
+ # Returns the last cached JWKs without performing a network request.
66
+ # @return [Array&lt;Hash&gt;, nil] cached keys, or nil if never fetched
67
+ def cached
68
+ @cached_keys
69
+ end
70
+
71
+ # Timestamp of the last successful fetch or revalidation.
72
+ # @return [Time, nil]
73
+ attr_reader :fetched_at
74
+
75
+ # Whether the cache is considered stale.
76
+ #
77
+ # Uses `Cache-Control: max-age` semantics when available:
78
+ # returns `true` if `max-age` has elapsed or nothing is cached.
79
+ #
80
+ # @return [Boolean]
81
+ def stale?
82
+ !fresh_by_ttl?
83
+ end
84
+
85
+ # @api private
86
+ # Wraps network/parse errors into {JwksCacheError} with structured codes.
87
+ # @raise [JwksCacheError]
88
+ def with_error_handling
89
+ yield
90
+ rescue JwksCacheError
91
+ raise
92
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError
93
+ raise JwksCacheError.new('Connection failed', code: 'jwks_fetch_failed')
94
+ rescue Faraday::Error => e
95
+ raise JwksCacheError.new("JWKS fetch failed: #{e.message}", code: 'jwks_fetch_failed')
96
+ rescue JSON::ParserError
97
+ raise JwksCacheError.new('Response is not valid JSON', code: 'jwks_parse_failed')
98
+ rescue StandardError => e
99
+ raise JwksCacheError.new("Unexpected JWKS fetch error: #{e.message}", code: 'jwks_fetch_failed')
100
+ end
101
+
102
+ # @api private
103
+ # Builds conditional headers for revalidation.
104
+ # @return [Hash] `{ 'If-None-Match' =&gt; etag }` when present, otherwise `{}`.
105
+ def build_conditional_headers
106
+ @etag ? { 'If-None-Match' => @etag } : {}
107
+ end
108
+
109
+ # @api private
110
+ # True when cached keys are still fresh per `Cache-Control: max-age`.
111
+ # @return [Boolean]
112
+ def fresh_by_ttl?
113
+ return false unless @cached_keys && @fetched_at && @max_age
114
+
115
+ (Time.now - @fetched_at) < @max_age
116
+ end
117
+
118
+ # @api private
119
+ # Parses a `Cache-Control` header and extracts `max-age` in seconds.
120
+ #
121
+ # Ignores `no-store` / `no-cache`. Returns `nil` when `max-age` is not present or invalid.
122
+ #
123
+ # @param cache_control [String, nil]
124
+ # @return [Integer, nil] seconds
125
+ def extract_max_age(cache_control)
126
+ return nil unless cache_control
127
+
128
+ # Normalize and split directives
129
+ directives = cache_control.to_s.downcase.split(',').map(&:strip)
130
+ return nil if directives.include?('no-store') || directives.include?('no-cache')
131
+
132
+ max_age_directive = directives.find { |d| d.start_with?('max-age=') }
133
+ return nil unless max_age_directive
134
+
135
+ value = max_age_directive.split('=', 2)[1]
136
+ Integer(value)
137
+ rescue ArgumentError
138
+ nil
139
+ end
140
+
141
+ # @api private
142
+ # Parses the response body into JSON.
143
+ # @param body [#to_s]
144
+ # @return [Hash]
145
+ # @raise [JwksCacheError] when JSON is invalid
146
+ def parse_json!(body)
147
+ JSON.parse(body.to_s)
148
+ rescue JSON::ParserError
149
+ raise JwksCacheError.new('Response is not valid JSON', code: 'jwks_parse_failed')
150
+ end
151
+
152
+ # @api private
153
+ # Extracts and validates the `keys` array from a JWKS JSON document.
154
+ # Ensures each key has `kid`, `kty`, `n`, and `e`.
155
+ #
156
+ # @param json [Hash]
157
+ # @return [Array&lt;Hash&gt;]
158
+ # @raise [JwksCacheError] when structure or attributes are invalid
159
+ def extract_and_validate_keys!(json)
160
+ keys = json['keys']
161
+ unless keys.is_a?(Array)
162
+ raise JwksCacheError.new("Response does not contain 'keys' array",
163
+ code: 'jwks_parse_failed')
164
+ end
165
+
166
+ keys.each_with_index do |key, idx|
167
+ %w[kid kty n e].each do |attr|
168
+ raise JwksCacheError.new("JWK at index #{idx} missing '#{attr}'", code: 'jwks_parse_failed') unless key[attr]
169
+ end
170
+ end
171
+
172
+ keys
173
+ end
174
+
175
+ # @api private
176
+ # Updates cached keys and freshness metadata from a 200 OK response.
177
+ #
178
+ # @param response [Faraday::Response]
179
+ # @param keys [Array&lt;Hash&gt;]
180
+ # @return [void]
181
+ def update_cache_from_ok(response, keys)
182
+ @cached_keys = keys
183
+
184
+ new_etag = response.headers['etag']
185
+ @etag = new_etag if new_etag
186
+
187
+ cache_control = response.headers['cache-control']
188
+ @max_age = extract_max_age(cache_control)
189
+ @fetched_at = Time.now
190
+ end
191
+
192
+ # @api private
193
+ # Dispatches handling based on HTTP status.
194
+ # @param response [Faraday::Response]
195
+ # @return [Array&lt;Hash&gt;]
196
+ # @raise [JwksCacheError]
197
+ def handle_response(response)
198
+ case response.status
199
+ when 200
200
+ process_successful_response(response)
201
+ when 304
202
+ # Revalidation succeeded; update freshness from 304 headers if present
203
+ process_not_modified(response)
204
+ else
205
+ raise JwksCacheError.new("Failed to fetch JWKS: status #{response.status}", code: 'jwks_fetch_failed')
206
+ end
207
+ end
208
+
209
+ # @api private
210
+ # Handles a 200 OK JWKS response.
211
+ # @param response [Faraday::Response]
212
+ # @return [Array&lt;Hash&gt;] parsed and cached keys
213
+ def process_successful_response(response)
214
+ json = parse_json!(response.body)
215
+ keys = extract_and_validate_keys!(json)
216
+ update_cache_from_ok(response, keys)
217
+ keys
218
+ end
219
+
220
+ # @api private
221
+ # Handles a 304 Not Modified JWKS response: updates TTL and timestamp, returns cached keys.
222
+ # @param response [Faraday::Response]
223
+ # @return [Array&lt;Hash&gt;]
224
+ # @raise [JwksCacheError] when cache is empty
225
+ def process_not_modified(response)
226
+ # Update TTL from response headers (some servers include Cache-Control on 304)
227
+ cache_control = response.headers['cache-control']
228
+ @max_age = extract_max_age(cache_control) || @max_age
229
+ @fetched_at = Time.now if @cached_keys
230
+ return_from_cache_or_fail
231
+ end
232
+
233
+ # @api private
234
+ # Returns cached keys or raises when 304 is received without prior cache.
235
+ # @return [Array&lt;Hash&gt;]
236
+ # @raise [JwksCacheError]
237
+ def return_from_cache_or_fail
238
+ @cached_keys || raise(JwksCacheError.new('JWKS cache is empty but received 304 Not Modified',
239
+ code: 'jwks_cache_miss'))
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+ require 'json'
5
+
6
+ module Verikloak
7
+ # Internal helper mixin that encapsulates error-to-HTTP mapping logic
8
+ # used by {Verikloak::Middleware}. By extracting this mapping into a
9
+ # separate module, the middleware class remains shorter and easier to
10
+ # reason about.
11
+ #
12
+ # This module does not depend on Rack internals; it only interprets
13
+ # Verikloak error objects and their `code` attributes.
14
+ #
15
+ # @api private
16
+ module MiddlewareErrorMapping
17
+ # Set of token/client-side error codes that should map to **401 Unauthorized**.
18
+ # @return [Array<String>]
19
+ AUTH_ERROR_CODES = %w[
20
+ invalid_token expired_token not_yet_valid invalid_issuer invalid_audience
21
+ invalid_signature unsupported_algorithm missing_authorization_header invalid_authorization_header
22
+ ].freeze
23
+
24
+ # Set of middleware/infrastructure error codes that should map to **503 Service Unavailable**.
25
+ # @return [Array<String>]
26
+ INFRA_ERROR_CODES = %w[jwks_fetch_failed jwks_cache_miss].freeze
27
+
28
+ # @param code [String, nil] short error code
29
+ # @return [Boolean] true if the error should be treated as a 403 Forbidden
30
+ def forbidden?(code)
31
+ code == 'forbidden'
32
+ end
33
+
34
+ # @param code [String, nil]
35
+ # @return [Boolean] true if the error belongs to {AUTH_ERROR_CODES}
36
+ def auth_error?(code)
37
+ code && AUTH_ERROR_CODES.include?(code)
38
+ end
39
+
40
+ # Maps dependency-layer errors to a pair of `[code, http_status]`.
41
+ #
42
+ # @param error [Exception]
43
+ # @return [Array(String, Integer), nil] two-element tuple or nil when not applicable
44
+ def dependency_error_tuple(error)
45
+ if error.is_a?(Verikloak::DiscoveryError)
46
+ [error.code || 'discovery_error', 503]
47
+ elsif error.is_a?(Verikloak::JwksCacheError)
48
+ [error.code || 'jwks_error', 503]
49
+ end
50
+ end
51
+
52
+ # Maps middleware infrastructure errors to a pair of `[code, http_status]`.
53
+ #
54
+ # @param error [Exception]
55
+ # @param code [String, nil]
56
+ # @return [Array(String, Integer), nil]
57
+ def infra_error_tuple(error, code)
58
+ return unless error.is_a?(Verikloak::MiddlewareError) && code && INFRA_ERROR_CODES.include?(code)
59
+
60
+ [code, 503]
61
+ end
62
+
63
+ # Final mapping fallback when no other rule has handled the error.
64
+ #
65
+ # @param error [Exception]
66
+ # @param code [String, nil]
67
+ # @return [Array(String, Integer)] two-element tuple
68
+ def fallback_tuple(error, code)
69
+ case error
70
+ when Verikloak::TokenDecoderError
71
+ ['invalid_token', 401]
72
+ when Verikloak::MiddlewareError
73
+ [code || 'invalid_token', 401]
74
+ else
75
+ ['internal_server_error', 500]
76
+ end
77
+ end
78
+ end
79
+
80
+ # Rack middleware that verifies incoming JWT access tokens (Keycloak) using
81
+ # OpenID Connect discovery and JWKS. On success, it populates:
82
+ #
83
+ # * `env['verikloak.token']` — the raw JWT string
84
+ # * `env['verikloak.user']` — the decoded JWT claims Hash
85
+ #
86
+ # Failures are converted to JSON error responses with appropriate status codes.
87
+ class Middleware
88
+ # @param app [#call] downstream Rack app
89
+ # @param discovery_url [String] OIDC discovery endpoint URL
90
+ # @param audience [String] Expected `aud` claim
91
+ # @param skip_paths [Array<String>] Literal paths or wildcard patterns to bypass auth
92
+ # @param discovery [Discovery, nil] Custom discovery instance (for DI/tests)
93
+ # @param jwks_cache [JwksCache, nil] Custom JWKS cache instance (for DI/tests)
94
+ def initialize(app,
95
+ discovery_url:,
96
+ audience:,
97
+ skip_paths: [],
98
+ discovery: nil,
99
+ jwks_cache: nil)
100
+ @app = app
101
+ @audience = audience
102
+ @skip_paths = skip_paths
103
+ @discovery = discovery || Discovery.new(discovery_url: discovery_url)
104
+ @jwks_cache = jwks_cache
105
+ @issuer = nil
106
+ @mutex = Mutex.new
107
+ end
108
+
109
+ # Rack entrypoint.
110
+ #
111
+ # @param env [Hash] Rack environment
112
+ # @return [Array(Integer, Hash, Array<String>)] standard Rack response
113
+ def call(env)
114
+ path = env['PATH_INFO']
115
+ return @app.call(env) if skip?(path)
116
+
117
+ token = extract_token(env)
118
+
119
+ handle_request(env, token)
120
+ rescue Verikloak::Error => e
121
+ code, status = map_error(e)
122
+ error_response(code, e.message, status)
123
+ rescue StandardError => e
124
+ log_internal_error(e)
125
+ error_response('internal_server_error', 'An unexpected error occurred', 500)
126
+ end
127
+
128
+ private
129
+
130
+ include MiddlewareErrorMapping
131
+
132
+ # Determines whether a token verification failure warrants a one-time JWKS refresh
133
+ # and retry (e.g., after key rotation).
134
+ #
135
+ # @param error [Exception]
136
+ # @return [Boolean]
137
+ # @api private
138
+ def retryable_decoder_error?(error)
139
+ return false unless error.is_a?(TokenDecoderError)
140
+
141
+ return true if error.code == 'invalid_signature'
142
+ return true if error.code == 'invalid_token' && error.message&.include?('Key with kid=')
143
+
144
+ false
145
+ end
146
+
147
+ # Ensures JWKS are up-to-date by invoking {#ensure_jwks_cache!}.
148
+ # Errors are not swallowed and are handled by the caller.
149
+ #
150
+ # @return [void]
151
+ # @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError]
152
+ # @api private
153
+ def refresh_jwks!
154
+ # Ensure discovery has been performed so we have a jwks_cache instance.
155
+ ensure_jwks_cache!
156
+ end
157
+
158
+ # Checks whether the request path matches any skip pattern.
159
+ #
160
+ # Supported patterns:
161
+ # * `'/'` — matches only the root path
162
+ # * `'/foo/*'` — matches `/foo` itself and any nested path under it
163
+ # * `'/api/public'` — exact match only (no wildcard)
164
+ #
165
+ # @param path [String]
166
+ # @return [Boolean]
167
+ def skip?(path)
168
+ @skip_paths.any? do |pattern|
169
+ if pattern == '/'
170
+ path == '/'
171
+ elsif pattern.end_with?('/*')
172
+ prefix = pattern.chomp('/*')
173
+ path == prefix || path.start_with?("#{prefix}/")
174
+ else
175
+ path == pattern || path.start_with?("#{pattern}/")
176
+ end
177
+ end
178
+ end
179
+
180
+ # Verifies the token, stores result in Rack env, and forwards to the downstream app.
181
+ #
182
+ # @param env [Hash]
183
+ # @param token [String]
184
+ # @return [Array(Integer, Hash, Array<String>)]
185
+ def handle_request(env, token)
186
+ claims = decode_token(token)
187
+ env['verikloak.token'] = token
188
+ env['verikloak.user'] = claims
189
+ @app.call(env)
190
+ end
191
+
192
+ # Extracts the Bearer token from the `Authorization` header.
193
+ #
194
+ # @param env [Hash]
195
+ # @return [String] the raw JWT string
196
+ # @raise [Verikloak::MiddlewareError] when the header is missing or malformed
197
+ def extract_token(env)
198
+ auth = env['HTTP_AUTHORIZATION']
199
+ if auth.to_s.strip.empty?
200
+ raise MiddlewareError.new('Missing Authorization header',
201
+ code: 'missing_authorization_header')
202
+ end
203
+
204
+ scheme, token = auth.split(' ', 2)
205
+ unless scheme && token && scheme.casecmp('Bearer').zero?
206
+ raise MiddlewareError.new('Invalid Authorization header format', code: 'invalid_authorization_header')
207
+ end
208
+
209
+ token
210
+ end
211
+
212
+ # Decodes and verifies the JWT using the cached JWKS. On certain verification
213
+ # failures (e.g., key rotation), it refreshes the JWKS and retries once.
214
+ #
215
+ # @param token [String]
216
+ # @return [Hash] decoded JWT claims
217
+ # @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
218
+ def decode_token(token)
219
+ ensure_jwks_cache!
220
+ if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
221
+ raise MiddlewareError.new('JWKS cache is empty, cannot verify token', code: 'jwks_cache_miss')
222
+ end
223
+
224
+ # First attempt
225
+ decoder = TokenDecoder.new(
226
+ jwks: @jwks_cache.cached,
227
+ issuer: @issuer,
228
+ audience: @audience
229
+ )
230
+
231
+ begin
232
+ decoder.decode!(token)
233
+ rescue TokenDecoderError => e
234
+ # On key rotation or signature mismatch, refresh JWKS and retry once.
235
+ raise unless retryable_decoder_error?(e)
236
+
237
+ refresh_jwks!
238
+
239
+ # Rebuild decoder with refreshed keys and try once more.
240
+ decoder = TokenDecoder.new(
241
+ jwks: @jwks_cache.cached,
242
+ issuer: @issuer,
243
+ audience: @audience
244
+ )
245
+ decoder.decode!(token)
246
+ end
247
+ end
248
+
249
+ # Ensures that discovery metadata and JWKS cache are initialized and up-to-date.
250
+ # This method is thread-safe.
251
+ #
252
+ # * When the cache instance is missing, it is created from discovery metadata.
253
+ # * JWKS are (re)fetched every time; ETag/Cache-Control headers minimize traffic.
254
+ #
255
+ # @return [void]
256
+ # @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
257
+ def ensure_jwks_cache!
258
+ @mutex.synchronize do
259
+ if @jwks_cache.nil?
260
+ config = @discovery.fetch!
261
+ @issuer = config['issuer']
262
+ jwks_uri = config['jwks_uri']
263
+ @jwks_cache = JwksCache.new(jwks_uri: jwks_uri)
264
+ end
265
+
266
+ @jwks_cache.fetch!
267
+ end
268
+ rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
269
+ # Re-raise so that specific error codes can be mapped in the middleware
270
+ raise e
271
+ rescue StandardError => e
272
+ raise MiddlewareError.new("Failed to initialize JWKS cache: #{e.message}", code: 'jwks_fetch_failed')
273
+ end
274
+
275
+ # Converts a raised error into a `[code, http_status]` tuple for response rendering.
276
+ #
277
+ # @param error [Exception]
278
+ # @return [Array(String, Integer)]
279
+ def map_error(error)
280
+ code = error.respond_to?(:code) ? error.code : nil
281
+
282
+ return [code, 403] if forbidden?(code)
283
+ return [code, 401] if auth_error?(code)
284
+
285
+ if (dep = dependency_error_tuple(error))
286
+ return dep
287
+ end
288
+
289
+ if (infra = infra_error_tuple(error, code))
290
+ return infra
291
+ end
292
+
293
+ fallback_tuple(error, code)
294
+ end
295
+
296
+ # Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
297
+ #
298
+ # @param code [String]
299
+ # @param message [String]
300
+ # @param status [Integer]
301
+ # @return [Array(Integer, Hash, Array<String>)] Rack response triple
302
+ def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
303
+ body = { error: code, message: message }.to_json
304
+ headers = { 'Content-Type' => 'application/json' }
305
+ if status == 401
306
+ headers['WWW-Authenticate'] =
307
+ %(Bearer realm="verikloak", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
308
+ end
309
+ [status, headers, [body]]
310
+ end
311
+
312
+ # Logs unexpected internal errors to STDERR (non-PII). Used for diagnostics only.
313
+ #
314
+ # @param error [Exception]
315
+ # @return [void]
316
+ # @api private
317
+ def log_internal_error(error)
318
+ warn "[verikloak] Internal error: #{error.class} - #{error.message}"
319
+ warn error.backtrace.join("\n") if error.backtrace
320
+ end
321
+ end
322
+ end