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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +324 -0
- data/lib/verikloak/discovery.rb +246 -0
- data/lib/verikloak/errors.rb +66 -0
- data/lib/verikloak/jwks_cache.rb +242 -0
- data/lib/verikloak/middleware.rb +322 -0
- data/lib/verikloak/token_decoder.rb +246 -0
- data/lib/verikloak/version.rb +6 -0
- data/lib/verikloak.rb +11 -0
- metadata +104 -0
|
@@ -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<Hash> 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<Hash>] 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<Hash>, 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' => 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<Hash>]
|
|
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<Hash>]
|
|
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<Hash>]
|
|
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<Hash>] 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<Hash>]
|
|
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<Hash>]
|
|
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
|