verikloak 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +27 -2
- data/README.md +106 -29
- data/lib/verikloak/errors.rb +2 -2
- data/lib/verikloak/jwks_cache.rb +25 -14
- data/lib/verikloak/middleware.rb +225 -128
- data/lib/verikloak/token_decoder.rb +26 -8
- data/lib/verikloak/version.rb +1 -1
- metadata +12 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fb6063573fea932d0e6a73ef18bc31b32be0d36d7c38cef34a7424bfbb3289a
|
|
4
|
+
data.tar.gz: 25cf61bedbbef60f8136bed435d640ab23993306d363db0ab2fc6fb997b08364
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82ab3fa49d2f1c82a46d3cd2573b390a744b96e35a688be413da86d715fd845de30ba5d76a83dd4c54e3e2777a1c0c24975c5005179d62a79679e5bf37651619
|
|
7
|
+
data.tar.gz: cc5a8fcd4a2679f2438390bb5cc1e0818866b162bfc2131724dae21af05fe16c59bf0c23fb70202ca0da34d7231eb720d4fec5c61a5e2405c6427a5d72a722b0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.1.3] - 2025-09-15
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Relax `jwt` runtime dependency to `>= 2.7, < 4.0` to allow jwt 3.x (PR #11).
|
|
14
|
+
|
|
15
|
+
### Chore
|
|
16
|
+
- Bump dev dependency `rubocop` to 1.80.2 (PR #13).
|
|
17
|
+
- Bump dev dependency `rubocop-rspec` to 3.7.0 (PR #12).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## [0.1.2] - 2025-08-31
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Middleware: new `connection:` option to inject a Faraday::Connection, shared by Discovery and JWKs.
|
|
25
|
+
- Middleware: new `leeway:` and `token_verify_options:` options, delegated to TokenDecoder.
|
|
26
|
+
- README: documented usage of `connection`, leeway/options, and clarified `skip_paths` behavior.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- Middleware: `skip_paths` semantics clarified — plain paths are exact-match only, use `/*` for prefix matching.
|
|
30
|
+
- Middleware: TokenDecoder instances are now cached per JWKs fetch for performance improvement.
|
|
31
|
+
- Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
10
35
|
## [0.1.1] - 2025-08-24
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -24,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
49
|
- Rack middleware for verifying JWT access tokens from Keycloak
|
|
25
50
|
- Support for OpenID Connect Discovery (`.well-known/openid-configuration`)
|
|
26
51
|
- Handles up to 3 HTTP redirects and resolves relative `Location` headers
|
|
27
|
-
-
|
|
52
|
+
- JWKs fetching with in-memory caching and ETag validation
|
|
28
53
|
- RS256 JWT verification with `kid` matching
|
|
29
54
|
- Claim validation: `aud`, `iss`, `exp`, `nbf`
|
|
30
55
|
- Configurable via `discovery_url`, `audience`, and `skip_paths` options
|
|
@@ -41,7 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
41
66
|
- RuboCop static analysis configuration
|
|
42
67
|
- Structured error handling & responses:
|
|
43
68
|
- Token/auth errors → **401 Unauthorized** with `WWW-Authenticate` header (RFC 6750)
|
|
44
|
-
- Discovery/
|
|
69
|
+
- Discovery/JWKs errors → **503 Service Unavailable**
|
|
45
70
|
- Structured error codes: `invalid_token`, `expired_token`, `not_yet_valid`,
|
|
46
71
|
`invalid_issuer`, `invalid_audience`, `unsupported_algorithm`,
|
|
47
72
|
`jwks_fetch_failed`, `jwks_parse_failed`, `jwks_cache_miss`,
|
data/README.md
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/verikloak)
|
|
5
|
-

|
|
6
6
|
[](https://rubygems.org/gems/verikloak)
|
|
7
7
|
|
|
8
8
|
A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenID Connect.
|
|
9
9
|
|
|
10
|
-
Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming `Bearer` tokens issued by Keycloak. It uses OpenID Connect Discovery and
|
|
10
|
+
Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that need to validate incoming `Bearer` tokens issued by Keycloak. It uses OpenID Connect Discovery and JWKs to fetch the public keys and verify JWT signatures securely.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
## Features
|
|
15
15
|
|
|
16
16
|
- OpenID Connect Discovery (`.well-known/openid-configuration`)
|
|
17
|
-
- JWKs auto-
|
|
17
|
+
- JWKs auto-fetching with in-memory caching and ETag support
|
|
18
18
|
- RS256 JWT verification using `kid`
|
|
19
19
|
- `aud`, `iss`, `exp`, `nbf` claim validation
|
|
20
20
|
- Rails/Rack middleware support
|
|
@@ -53,21 +53,44 @@ config.middleware.use Verikloak::Middleware,
|
|
|
53
53
|
|
|
54
54
|
#### Handling Authentication Failures
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
When you use the Rack middleware, authentication failures are automatically converted into JSON error responses (for example, `401` for token issues, `503` for JWKs/discovery errors). In most cases **you do not need to add custom `rescue_from` handlers** in Rails controllers.
|
|
57
|
+
|
|
58
|
+
If you use Verikloak components directly (bypassing the Rack middleware) or prefer centralized error handling, rescue from the base class `Verikloak::Error`. You can also match subclasses such as `Verikloak::TokenDecoderError`, `Verikloak::DiscoveryError`, or `Verikloak::JwksCacheError` depending on your needs:
|
|
58
59
|
|
|
59
60
|
```ruby
|
|
60
61
|
class ApplicationController < ActionController::API
|
|
61
|
-
rescue_from Verikloak::
|
|
62
|
-
|
|
62
|
+
rescue_from Verikloak::Error do |e|
|
|
63
|
+
status =
|
|
64
|
+
case e
|
|
65
|
+
when Verikloak::TokenDecoderError
|
|
66
|
+
:unauthorized
|
|
67
|
+
when Verikloak::DiscoveryError, Verikloak::JwksCacheError
|
|
68
|
+
:service_unavailable
|
|
69
|
+
else
|
|
70
|
+
:unauthorized
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
render json: { error: e.class.name, message: e.message }, status: status
|
|
63
74
|
end
|
|
64
75
|
end
|
|
65
76
|
```
|
|
66
77
|
|
|
67
|
-
This ensures
|
|
78
|
+
This ensures that even if you bypass the middleware, clients still receive
|
|
79
|
+
structured error responses.
|
|
80
|
+
|
|
81
|
+
> **Note:** When the Rack middleware is enabled, it already renders JSON error responses.
|
|
82
|
+
> The `rescue_from` example above is only necessary if you bypass the middleware or want custom behavior.
|
|
68
83
|
|
|
69
84
|
---
|
|
85
|
+
#### Error Hierarchy
|
|
70
86
|
|
|
87
|
+
All Verikloak errors inherit from `Verikloak::Error`:
|
|
88
|
+
|
|
89
|
+
- `Verikloak::TokenDecoderError` – token parsing/verification (`401 Unauthorized`)
|
|
90
|
+
- `Verikloak::DiscoveryError` – OIDC discovery fetch/parse (`503 Service Unavailable`)
|
|
91
|
+
- `Verikloak::JwksCacheError` – JWKs fetch/parse/cache (`503 Service Unavailable`)
|
|
92
|
+
- `Verikloak::MiddlewareError` – header/infra issues surfaced by the middleware (usually `401`, sometimes `503`)
|
|
93
|
+
---
|
|
71
94
|
#### Recommended: use environment variables in production
|
|
72
95
|
|
|
73
96
|
```ruby
|
|
@@ -76,14 +99,7 @@ config.middleware.use Verikloak::Middleware,
|
|
|
76
99
|
audience: ENV.fetch("CLIENT_ID"),
|
|
77
100
|
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
78
101
|
```
|
|
79
|
-
#### In production, set these variables in your environment for security and flexibility.
|
|
80
|
-
|
|
81
102
|
This makes the configuration secure and flexible across environments.
|
|
82
|
-
|
|
83
|
-
```ruby
|
|
84
|
-
request.env["verikloak.user"] # => JWT claims hash
|
|
85
|
-
request.env["verikloak.token"] # => Raw JWT string
|
|
86
|
-
```
|
|
87
103
|
---
|
|
88
104
|
### Accessing claims in controllers
|
|
89
105
|
|
|
@@ -123,7 +139,7 @@ run ->(env) {
|
|
|
123
139
|
|
|
124
140
|
1. Extracts the `Authorization: Bearer <token>` header
|
|
125
141
|
2. Fetches the OIDC discovery document (only once or when expired)
|
|
126
|
-
3. Downloads
|
|
142
|
+
3. Downloads JWKs public keys from the provided `jwks_uri`
|
|
127
143
|
4. Matches the `kid` from JWT header to select the right JWK
|
|
128
144
|
5. Decodes and verifies the JWT using `RS256`
|
|
129
145
|
6. Validates the following claims:
|
|
@@ -137,12 +153,12 @@ run ->(env) {
|
|
|
137
153
|
|
|
138
154
|
## Error Responses
|
|
139
155
|
|
|
140
|
-
Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/
|
|
156
|
+
Verikloak returns JSON error responses in a consistent format with structured error codes. The HTTP status code reflects the nature of the error: 401 for client-side authentication issues, 503 for server-side discovery/JWKs errors, and 500 for unexpected internal errors.
|
|
141
157
|
|
|
142
158
|
### Common HTTP Responses
|
|
143
159
|
|
|
144
160
|
- `401 Unauthorized`: The access token is missing, invalid, expired, or otherwise not valid.
|
|
145
|
-
- `503 Service Unavailable`: Discovery or
|
|
161
|
+
- `503 Service Unavailable`: Discovery or JWKs fetch/parsing failed (server-side issue).
|
|
146
162
|
- `500 Internal Server Error`: An unexpected error occurred.
|
|
147
163
|
|
|
148
164
|
### Representative Examples
|
|
@@ -164,14 +180,14 @@ Verikloak returns JSON error responses in a consistent format with structured er
|
|
|
164
180
|
```json
|
|
165
181
|
{
|
|
166
182
|
"error": "jwks_fetch_failed",
|
|
167
|
-
"message": "Failed to fetch
|
|
183
|
+
"message": "Failed to fetch JWKs"
|
|
168
184
|
}
|
|
169
185
|
```
|
|
170
186
|
|
|
171
187
|
```json
|
|
172
188
|
{
|
|
173
189
|
"error": "jwks_parse_failed",
|
|
174
|
-
"message": "Failed to parse
|
|
190
|
+
"message": "Failed to parse JWKs"
|
|
175
191
|
}
|
|
176
192
|
```
|
|
177
193
|
|
|
@@ -202,15 +218,15 @@ Verikloak returns JSON error responses in a consistent format with structured er
|
|
|
202
218
|
| `invalid_issuer` | 401 Unauthorized | Invalid `iss` claim |
|
|
203
219
|
| `invalid_audience` | 401 Unauthorized | Invalid `aud` claim |
|
|
204
220
|
| `not_yet_valid` | 401 Unauthorized | The token is not yet valid (`nbf` in the future) |
|
|
205
|
-
| `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch
|
|
206
|
-
| `jwks_parse_failed` | 503 Service Unavailable | Failed to parse
|
|
207
|
-
| `jwks_cache_miss` | 503 Service Unavailable |
|
|
221
|
+
| `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch JWKs |
|
|
222
|
+
| `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKs |
|
|
223
|
+
| `jwks_cache_miss` | 503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
|
|
208
224
|
| `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
|
|
209
225
|
| `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
|
|
210
226
|
| `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
|
|
211
227
|
| `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
|
|
212
228
|
|
|
213
|
-
> Note
|
|
229
|
+
> **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
|
|
214
230
|
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
215
231
|
|
|
216
232
|
For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
|
|
@@ -224,27 +240,88 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
224
240
|
| `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
|
|
225
241
|
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
226
242
|
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
243
|
+
| `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
|
|
244
|
+
| `token_verify_options` | No | Hash of advanced JWT verification options passed through to TokenDecoder. For example: `{ verify_iat: false, leeway: 10, algorithms: ["RS256"] }`. If both `leeway:` and `token_verify_options[:leeway]` are set, the latter takes precedence. |
|
|
245
|
+
| `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Allows unified timeout, retry, and headers. |
|
|
227
246
|
|
|
228
247
|
#### Option: `skip_paths`
|
|
229
248
|
|
|
249
|
+
Plain paths are exact-match only, while `/*` at the end enables prefix matching.
|
|
250
|
+
|
|
230
251
|
`skip_paths` lets you specify paths (or wildcard patterns) where authentication should be **skipped**.
|
|
231
252
|
For example:
|
|
232
253
|
|
|
233
254
|
```ruby
|
|
234
|
-
skip_paths: ['/', '/health', '/
|
|
255
|
+
skip_paths: ['/', '/health', '/rails/*', '/public/src']
|
|
235
256
|
```
|
|
236
257
|
- `'/'` matches only the root path.
|
|
237
|
-
- `'/
|
|
238
|
-
- `'/api/public'` matches **only** `/api/public` (for subpaths, use `'/api/public/*'`).
|
|
258
|
+
- `'/health'` matches **only** `/health` (for subpaths, use `'/health/*'`).
|
|
239
259
|
- `'/rails/*'` matches `/rails` itself as well as `/rails/foo`, `/rails/foo/bar`, etc.
|
|
260
|
+
- `'/public/src'` matches `/public/src`, but does **not** match `/public`, any subpath like `/public/src/html` or other siblings like `/public/css`.
|
|
240
261
|
|
|
241
262
|
Paths **not matched** by any `skip_paths` entry will require a valid JWT.
|
|
242
263
|
|
|
243
264
|
**Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
|
|
244
265
|
Internally, `*` expands to match nested paths, so patterns like `/rails/*` are valid. This differs from regex — for example, `'/rails'` alone matches only `/rails`, while `'/rails/*'` covers both `/rails` and deeper subpaths.
|
|
245
266
|
|
|
267
|
+
#### Customizing Faraday for Discovery and JWKs
|
|
268
|
+
|
|
269
|
+
Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
|
|
270
|
+
This allows you to configure timeouts, retries, logging, and shared headers:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
connection = Faraday.new(request: { timeout: 5 }) do |f|
|
|
274
|
+
f.response :logger
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
config.middleware.use Verikloak::Middleware,
|
|
278
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
279
|
+
audience: ENV["CLIENT_ID"],
|
|
280
|
+
jwks_cache: Verikloak::JwksCache.new(
|
|
281
|
+
jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
|
|
282
|
+
connection: connection
|
|
283
|
+
)
|
|
284
|
+
```
|
|
285
|
+
This makes it easy to apply consistent Faraday settings across both discovery and JWKs fetches.
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# Alternatively, you can pass the connection directly to the middleware:
|
|
289
|
+
config.middleware.use Verikloak::Middleware,
|
|
290
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
291
|
+
audience: ENV["CLIENT_ID"],
|
|
292
|
+
connection: connection
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
#### Customizing token verification (leeway and options)
|
|
296
|
+
|
|
297
|
+
You can fine-tune how JWTs are verified by setting `leeway` or providing advanced options via `token_verify_options`. For example:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
config.middleware.use Verikloak::Middleware,
|
|
301
|
+
discovery_url: ENV["DISCOVERY_URL"],
|
|
302
|
+
audience: ENV["CLIENT_ID"],
|
|
303
|
+
leeway: 30, # allow 30s clock skew
|
|
304
|
+
token_verify_options: {
|
|
305
|
+
verify_iat: true,
|
|
306
|
+
verify_expiration: true,
|
|
307
|
+
verify_not_before: true,
|
|
308
|
+
# algorithms: ["RS256"] # override algorithms if needed
|
|
309
|
+
# leeway: 10 # this overrides the top-level leeway
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- `leeway:` sets the default skew tolerance in seconds.
|
|
314
|
+
- `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
|
|
315
|
+
- If both are set, `token_verify_options[:leeway]` takes precedence.
|
|
316
|
+
|
|
246
317
|
---
|
|
247
318
|
|
|
319
|
+
#### Performance note
|
|
320
|
+
|
|
321
|
+
Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
|
|
322
|
+
them on every request. This improves performance while still ensuring that keys are
|
|
323
|
+
revalidated when JWKs is refreshed.
|
|
324
|
+
|
|
248
325
|
## Architecture
|
|
249
326
|
|
|
250
327
|
Verikloak consists of modular components, each with a focused responsibility:
|
|
@@ -253,7 +330,7 @@ Verikloak consists of modular components, each with a focused responsibility:
|
|
|
253
330
|
|----------------|--------------------------------------------------------|--------------|
|
|
254
331
|
| `Middleware` | Rack-compatible entry point for token validation | Rack layer |
|
|
255
332
|
| `Discovery` | Fetches OIDC discovery metadata (`.well-known`) | Network layer|
|
|
256
|
-
| `JwksCache` | Fetches & caches
|
|
333
|
+
| `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
|
|
257
334
|
| `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
|
|
258
335
|
| `Errors` | Centralized error hierarchy | Core layer |
|
|
259
336
|
|
|
@@ -321,4 +398,4 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
|
321
398
|
|
|
322
399
|
- [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
323
400
|
- [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
|
|
324
|
-
- [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
|
|
401
|
+
- [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
|
data/lib/verikloak/errors.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Verikloak
|
|
|
35
35
|
|
|
36
36
|
# Raised for middleware-level failures while processing a Rack request.
|
|
37
37
|
#
|
|
38
|
-
# Examples include missing/invalid Authorization headers,
|
|
38
|
+
# Examples include missing/invalid Authorization headers, JWKs cache
|
|
39
39
|
# initialization failures, or infrastructure issues detected by the
|
|
40
40
|
# middleware itself.
|
|
41
41
|
#
|
|
@@ -55,7 +55,7 @@ module Verikloak
|
|
|
55
55
|
# @raise [TokenDecoderError] from {Verikloak::TokenDecoder#decode!}
|
|
56
56
|
class TokenDecoderError < Error; end
|
|
57
57
|
|
|
58
|
-
# Raised when
|
|
58
|
+
# Raised when JWKs fetching, validation, or cache handling fails.
|
|
59
59
|
#
|
|
60
60
|
# Causes include HTTP failures, invalid JSON, missing required JWK fields,
|
|
61
61
|
# or receiving 304 Not Modified without a prior cached value.
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -4,14 +4,14 @@ require 'faraday'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
6
|
module Verikloak
|
|
7
|
-
# Caches and revalidates JSON Web Key Sets (
|
|
7
|
+
# Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.
|
|
8
8
|
#
|
|
9
9
|
# This cache supports two HTTP cache mechanisms:
|
|
10
10
|
# - **ETag revalidation** via `If-None-Match` → returns `304 Not Modified` when unchanged.
|
|
11
11
|
# - **TTL freshness** via `Cache-Control: max-age` → avoids HTTP requests while fresh.
|
|
12
12
|
#
|
|
13
13
|
# On a successful `200 OK`, the cache:
|
|
14
|
-
# - Parses the
|
|
14
|
+
# - Parses the JWKs JSON (`{"keys":[...]}`) and validates each JWK has `kid`, `kty`, `n`, `e`.
|
|
15
15
|
# - Stores the keys in-memory, records `ETag`, and computes freshness from `Cache-Control`.
|
|
16
16
|
#
|
|
17
17
|
# On a `304 Not Modified`, the cache:
|
|
@@ -28,22 +28,29 @@ module Verikloak
|
|
|
28
28
|
#
|
|
29
29
|
# @see #fetch!
|
|
30
30
|
# @see #cached
|
|
31
|
+
#
|
|
32
|
+
# ## Dependency Injection
|
|
33
|
+
# Pass a preconfigured `Faraday::Connection` via `connection:` to control timeouts,
|
|
34
|
+
# adapters, and shared headers (kept consistent with Discovery).
|
|
35
|
+
# `JwksCache.new(jwks_uri: "...", connection: Faraday.new { |f| f.request :retry })`
|
|
31
36
|
class JwksCache
|
|
32
|
-
# @param jwks_uri [String] HTTPS URL of the
|
|
37
|
+
# @param jwks_uri [String] HTTPS URL of the JWKs endpoint
|
|
38
|
+
# @param connection [Faraday::Connection, nil] Optional Faraday connection for HTTP requests
|
|
33
39
|
# @raise [JwksCacheError] if the URI is not an HTTP(S) URL
|
|
34
|
-
def initialize(jwks_uri:)
|
|
40
|
+
def initialize(jwks_uri:, connection: nil)
|
|
35
41
|
unless jwks_uri.is_a?(String) && jwks_uri.strip.match?(%r{^https?://})
|
|
36
|
-
raise JwksCacheError.new('Invalid
|
|
42
|
+
raise JwksCacheError.new('Invalid JWKs URI: must be a non-empty HTTP(S) URL', code: 'jwks_fetch_failed')
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
@jwks_uri = jwks_uri
|
|
46
|
+
@connection = connection || Faraday.new
|
|
40
47
|
@cached_keys = nil
|
|
41
48
|
@etag = nil
|
|
42
49
|
@fetched_at = nil
|
|
43
50
|
@max_age = nil
|
|
44
51
|
end
|
|
45
52
|
|
|
46
|
-
# Fetches the
|
|
53
|
+
# Fetches the JWKs and updates the in-memory cache.
|
|
47
54
|
#
|
|
48
55
|
# Performs an HTTP GET with `If-None-Match` when an ETag is present and handles:
|
|
49
56
|
# - 200: parses/validates body, updates keys, ETag, TTL and `fetched_at`.
|
|
@@ -56,7 +63,7 @@ module Verikloak
|
|
|
56
63
|
# Build conditional request headers (ETag-based)
|
|
57
64
|
headers = build_conditional_headers
|
|
58
65
|
# Perform HTTP GET request
|
|
59
|
-
response =
|
|
66
|
+
response = @connection.get(@jwks_uri, nil, headers)
|
|
60
67
|
# Handle HTTP response according to status code
|
|
61
68
|
handle_response(response)
|
|
62
69
|
end
|
|
@@ -72,6 +79,10 @@ module Verikloak
|
|
|
72
79
|
# @return [Time, nil]
|
|
73
80
|
attr_reader :fetched_at
|
|
74
81
|
|
|
82
|
+
# Injected Faraday connection (for testing and shared config across the gem)
|
|
83
|
+
# @return [Faraday::Connection]
|
|
84
|
+
attr_reader :connection
|
|
85
|
+
|
|
75
86
|
# Whether the cache is considered stale.
|
|
76
87
|
#
|
|
77
88
|
# Uses `Cache-Control: max-age` semantics when available:
|
|
@@ -92,11 +103,11 @@ module Verikloak
|
|
|
92
103
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError
|
|
93
104
|
raise JwksCacheError.new('Connection failed', code: 'jwks_fetch_failed')
|
|
94
105
|
rescue Faraday::Error => e
|
|
95
|
-
raise JwksCacheError.new("
|
|
106
|
+
raise JwksCacheError.new("JWKs fetch failed: #{e.message}", code: 'jwks_fetch_failed')
|
|
96
107
|
rescue JSON::ParserError
|
|
97
108
|
raise JwksCacheError.new('Response is not valid JSON', code: 'jwks_parse_failed')
|
|
98
109
|
rescue StandardError => e
|
|
99
|
-
raise JwksCacheError.new("Unexpected
|
|
110
|
+
raise JwksCacheError.new("Unexpected JWKs fetch error: #{e.message}", code: 'jwks_fetch_failed')
|
|
100
111
|
end
|
|
101
112
|
|
|
102
113
|
# @api private
|
|
@@ -150,7 +161,7 @@ module Verikloak
|
|
|
150
161
|
end
|
|
151
162
|
|
|
152
163
|
# @api private
|
|
153
|
-
# Extracts and validates the `keys` array from a
|
|
164
|
+
# Extracts and validates the `keys` array from a JWKs JSON document.
|
|
154
165
|
# Ensures each key has `kid`, `kty`, `n`, and `e`.
|
|
155
166
|
#
|
|
156
167
|
# @param json [Hash]
|
|
@@ -202,12 +213,12 @@ module Verikloak
|
|
|
202
213
|
# Revalidation succeeded; update freshness from 304 headers if present
|
|
203
214
|
process_not_modified(response)
|
|
204
215
|
else
|
|
205
|
-
raise JwksCacheError.new("Failed to fetch
|
|
216
|
+
raise JwksCacheError.new("Failed to fetch JWKs: status #{response.status}", code: 'jwks_fetch_failed')
|
|
206
217
|
end
|
|
207
218
|
end
|
|
208
219
|
|
|
209
220
|
# @api private
|
|
210
|
-
# Handles a 200 OK
|
|
221
|
+
# Handles a 200 OK JWKs response.
|
|
211
222
|
# @param response [Faraday::Response]
|
|
212
223
|
# @return [Array<Hash>] parsed and cached keys
|
|
213
224
|
def process_successful_response(response)
|
|
@@ -218,7 +229,7 @@ module Verikloak
|
|
|
218
229
|
end
|
|
219
230
|
|
|
220
231
|
# @api private
|
|
221
|
-
# Handles a 304 Not Modified
|
|
232
|
+
# Handles a 304 Not Modified JWKs response: updates TTL and timestamp, returns cached keys.
|
|
222
233
|
# @param response [Faraday::Response]
|
|
223
234
|
# @return [Array<Hash>]
|
|
224
235
|
# @raise [JwksCacheError] when cache is empty
|
|
@@ -235,7 +246,7 @@ module Verikloak
|
|
|
235
246
|
# @return [Array<Hash>]
|
|
236
247
|
# @raise [JwksCacheError]
|
|
237
248
|
def return_from_cache_or_fail
|
|
238
|
-
@cached_keys || raise(JwksCacheError.new('
|
|
249
|
+
@cached_keys || raise(JwksCacheError.new('JWKs cache is empty but received 304 Not Modified',
|
|
239
250
|
code: 'jwks_cache_miss'))
|
|
240
251
|
end
|
|
241
252
|
end
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -2,17 +2,204 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rack'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'set'
|
|
6
|
+
require 'faraday'
|
|
5
7
|
|
|
6
8
|
module Verikloak
|
|
7
|
-
#
|
|
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.
|
|
9
|
+
# @api private
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# Internal mixin for skip-path normalization and matching.
|
|
12
|
+
# Extracted from Middleware to reduce class length and improve testability.
|
|
13
|
+
module SkipPathMatcher
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Checks whether the request path matches any compiled skip pattern.
|
|
17
|
+
#
|
|
18
|
+
# Supported patterns:
|
|
19
|
+
# * `'/'` — matches only the root path
|
|
20
|
+
# * `'/foo'` — exact-match only (matches `/foo` but **not** `/foo/...`)
|
|
21
|
+
# * `'/foo/*'` — prefix match (matches `/foo` and any nested path under it)
|
|
22
|
+
#
|
|
23
|
+
# @param path [String]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def skip?(path)
|
|
26
|
+
np = normalize_path(path)
|
|
27
|
+
return true if @skip_root && np == '/'
|
|
28
|
+
return true if @skip_exacts.include?(np)
|
|
29
|
+
|
|
30
|
+
@skip_prefixes.any? { |prefix| np == prefix || np.start_with?("#{prefix}/") }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Normalizes paths for stable comparisons:
|
|
34
|
+
# - ensures leading slash
|
|
35
|
+
# - collapses multiple slashes (e.g. //foo///bar -> /foo/bar)
|
|
36
|
+
# - removes trailing slash except for root
|
|
37
|
+
#
|
|
38
|
+
# @param path [String, nil]
|
|
39
|
+
# @return [String]
|
|
40
|
+
def normalize_path(path)
|
|
41
|
+
s = (path || '').to_s
|
|
42
|
+
s = "/#{s}" unless s.start_with?('/')
|
|
43
|
+
s = s.gsub(%r{/+}, '/')
|
|
44
|
+
s.length > 1 ? s.chomp('/') : s
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Pre-compiles {skip_paths} into fast lookup structures.
|
|
48
|
+
#
|
|
49
|
+
# * `@skip_root` — whether `'/'` is present
|
|
50
|
+
# * `@skip_exacts` — exact-match set (e.g. `'/health'`)
|
|
51
|
+
# * `@skip_prefixes` — wildcard prefixes for `'/*'` (e.g. `'/public'`)
|
|
52
|
+
#
|
|
53
|
+
# @param paths [Array<String>]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def compile_skip_paths(paths)
|
|
56
|
+
@skip_root = false
|
|
57
|
+
@skip_exacts = Set.new
|
|
58
|
+
@skip_prefixes = []
|
|
59
|
+
|
|
60
|
+
Array(paths).each do |raw|
|
|
61
|
+
next if raw.nil?
|
|
62
|
+
|
|
63
|
+
s = raw.to_s.strip
|
|
64
|
+
next if s.empty?
|
|
65
|
+
|
|
66
|
+
if s == '/'
|
|
67
|
+
@skip_root = true
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if s.end_with?('/*')
|
|
72
|
+
prefix = normalize_path(s.chomp('/*'))
|
|
73
|
+
next if prefix == '/' # root is handled by @skip_root
|
|
74
|
+
|
|
75
|
+
@skip_prefixes << prefix
|
|
76
|
+
else
|
|
77
|
+
exact = normalize_path(s)
|
|
78
|
+
@skip_exacts << exact
|
|
79
|
+
# Do NOT add to @skip_prefixes here; plain '/foo' is exact-match only.
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@skip_prefixes.uniq!
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
14
88
|
#
|
|
89
|
+
# Internal mixin for JWT verification and discovery/JWKs management.
|
|
90
|
+
# Extracted from Middleware to reduce class length and improve clarity.
|
|
91
|
+
module MiddlewareTokenVerification
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Determines whether a token verification failure warrants a one-time JWKs refresh
|
|
95
|
+
# and retry (e.g., after key rotation).
|
|
96
|
+
#
|
|
97
|
+
# @param error [Exception]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def retryable_decoder_error?(error)
|
|
100
|
+
return false unless error.is_a?(TokenDecoderError)
|
|
101
|
+
return true if error.code == 'invalid_signature'
|
|
102
|
+
return true if error.code == 'invalid_token' && error.message&.include?('Key with kid=')
|
|
103
|
+
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns a cached TokenDecoder instance for current inputs.
|
|
108
|
+
# Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
|
|
109
|
+
def decoder_for
|
|
110
|
+
keys = @jwks_cache.cached
|
|
111
|
+
fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
|
|
112
|
+
cache_key = [
|
|
113
|
+
@issuer,
|
|
114
|
+
@audience,
|
|
115
|
+
@leeway,
|
|
116
|
+
@token_verify_options,
|
|
117
|
+
fetched_at
|
|
118
|
+
].hash
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
@decoder_cache[cache_key] ||= TokenDecoder.new(
|
|
121
|
+
jwks: keys,
|
|
122
|
+
issuer: @issuer,
|
|
123
|
+
audience: @audience,
|
|
124
|
+
leeway: @leeway,
|
|
125
|
+
options: @token_verify_options
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Ensures JWKs are up-to-date by invoking {#ensure_jwks_cache!}.
|
|
131
|
+
# Errors are not swallowed and are handled by the caller.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError]
|
|
135
|
+
def refresh_jwks!
|
|
136
|
+
ensure_jwks_cache!
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Decodes and verifies the JWT using the cached JWKs. On certain verification
|
|
140
|
+
# failures (e.g., key rotation), it refreshes the JWKs and retries once.
|
|
141
|
+
#
|
|
142
|
+
# @param token [String]
|
|
143
|
+
# @return [Hash] decoded JWT claims
|
|
144
|
+
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
145
|
+
def decode_token(token)
|
|
146
|
+
ensure_jwks_cache!
|
|
147
|
+
if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
|
|
148
|
+
raise MiddlewareError.new('JWKs cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# First attempt
|
|
152
|
+
decoder = decoder_for
|
|
153
|
+
|
|
154
|
+
begin
|
|
155
|
+
decoder.decode!(token)
|
|
156
|
+
rescue TokenDecoderError => e
|
|
157
|
+
# On key rotation or signature mismatch, refresh JWKs and retry once.
|
|
158
|
+
raise unless retryable_decoder_error?(e)
|
|
159
|
+
|
|
160
|
+
refresh_jwks!
|
|
161
|
+
|
|
162
|
+
# Rebuild decoder with refreshed keys and try once more.
|
|
163
|
+
decoder = decoder_for
|
|
164
|
+
decoder.decode!(token)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
|
|
169
|
+
# This method is thread-safe.
|
|
170
|
+
#
|
|
171
|
+
# * When the cache instance is missing, it is created from discovery metadata.
|
|
172
|
+
# * JWKs are (re)fetched every time; ETag/Cache-Control headers minimize traffic.
|
|
173
|
+
#
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
|
|
176
|
+
def ensure_jwks_cache!
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
if @jwks_cache.nil?
|
|
179
|
+
config = @discovery.fetch!
|
|
180
|
+
@issuer = config['issuer']
|
|
181
|
+
jwks_uri = config['jwks_uri']
|
|
182
|
+
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@jwks_cache.fetch!
|
|
186
|
+
end
|
|
187
|
+
rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
|
|
188
|
+
# Re-raise so that specific error codes can be mapped in the middleware
|
|
189
|
+
raise e
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
raise MiddlewareError.new("Failed to initialize JWKs cache: #{e.message}", code: 'jwks_fetch_failed')
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
15
195
|
# @api private
|
|
196
|
+
#
|
|
197
|
+
# Internal mixin that encapsulates error-to-HTTP mapping logic used by
|
|
198
|
+
# {Verikloak::Middleware}. By extracting this mapping into a separate module,
|
|
199
|
+
# the middleware class stays concise and easier to reason about.
|
|
200
|
+
#
|
|
201
|
+
# This module does not depend on Rack internals; it only interprets
|
|
202
|
+
# Verikloak error objects and their `code` attributes.
|
|
16
203
|
module MiddlewareErrorMapping
|
|
17
204
|
# Set of token/client-side error codes that should map to **401 Unauthorized**.
|
|
18
205
|
# @return [Array<String>]
|
|
@@ -25,6 +212,8 @@ module Verikloak
|
|
|
25
212
|
# @return [Array<String>]
|
|
26
213
|
INFRA_ERROR_CODES = %w[jwks_fetch_failed jwks_cache_miss].freeze
|
|
27
214
|
|
|
215
|
+
private
|
|
216
|
+
|
|
28
217
|
# @param code [String, nil] short error code
|
|
29
218
|
# @return [Boolean] true if the error should be treated as a 403 Forbidden
|
|
30
219
|
def forbidden?(code)
|
|
@@ -78,44 +267,60 @@ module Verikloak
|
|
|
78
267
|
end
|
|
79
268
|
|
|
80
269
|
# Rack middleware that verifies incoming JWT access tokens (Keycloak) using
|
|
81
|
-
# OpenID Connect discovery and
|
|
270
|
+
# OpenID Connect discovery and JWKs. On success, it populates:
|
|
82
271
|
#
|
|
83
272
|
# * `env['verikloak.token']` — the raw JWT string
|
|
84
273
|
# * `env['verikloak.user']` — the decoded JWT claims Hash
|
|
85
274
|
#
|
|
86
275
|
# Failures are converted to JSON error responses with appropriate status codes.
|
|
87
276
|
class Middleware
|
|
277
|
+
include MiddlewareErrorMapping
|
|
278
|
+
include SkipPathMatcher
|
|
279
|
+
include MiddlewareTokenVerification
|
|
280
|
+
|
|
88
281
|
# @param app [#call] downstream Rack app
|
|
89
282
|
# @param discovery_url [String] OIDC discovery endpoint URL
|
|
90
|
-
# @param audience [String]
|
|
91
|
-
# @param skip_paths [Array<String>]
|
|
92
|
-
# @param discovery [Discovery, nil]
|
|
93
|
-
# @param jwks_cache [JwksCache, nil]
|
|
283
|
+
# @param audience [String] expected `aud` claim
|
|
284
|
+
# @param skip_paths [Array<String>] literal paths or wildcard patterns to bypass auth
|
|
285
|
+
# @param discovery [Discovery, nil] custom discovery instance (for DI/tests)
|
|
286
|
+
# @param jwks_cache [JwksCache, nil] custom JWKs cache instance (for DI/tests)
|
|
287
|
+
# @param connection [Faraday::Connection, nil] Optional injected Faraday connection (defaults to Faraday.new)
|
|
288
|
+
# @param leeway [Integer] Clock skew tolerance in seconds for token verification (delegated to TokenDecoder)
|
|
289
|
+
# @param token_verify_options [Hash] Additional JWT verification options passed through
|
|
290
|
+
# to TokenDecoder.
|
|
291
|
+
# e.g., { verify_iat: false, leeway: 10 }
|
|
94
292
|
def initialize(app,
|
|
95
293
|
discovery_url:,
|
|
96
294
|
audience:,
|
|
97
295
|
skip_paths: [],
|
|
98
296
|
discovery: nil,
|
|
99
|
-
jwks_cache: nil
|
|
297
|
+
jwks_cache: nil,
|
|
298
|
+
connection: nil,
|
|
299
|
+
leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
|
|
300
|
+
token_verify_options: {})
|
|
100
301
|
@app = app
|
|
101
302
|
@audience = audience
|
|
102
|
-
@skip_paths = skip_paths
|
|
103
303
|
@discovery = discovery || Discovery.new(discovery_url: discovery_url)
|
|
104
304
|
@jwks_cache = jwks_cache
|
|
305
|
+
@connection = connection || Faraday.new
|
|
306
|
+
@leeway = leeway
|
|
307
|
+
@token_verify_options = token_verify_options || {}
|
|
105
308
|
@issuer = nil
|
|
106
309
|
@mutex = Mutex.new
|
|
310
|
+
@decoder_cache = {}
|
|
311
|
+
|
|
312
|
+
compile_skip_paths(skip_paths)
|
|
107
313
|
end
|
|
108
314
|
|
|
109
315
|
# Rack entrypoint.
|
|
110
316
|
#
|
|
111
317
|
# @param env [Hash] Rack environment
|
|
112
|
-
# @return [Array(Integer, Hash, Array<String>)] standard Rack response
|
|
318
|
+
# @return [Array(Integer, Hash, Array<String>)] standard Rack response triple
|
|
113
319
|
def call(env)
|
|
114
320
|
path = env['PATH_INFO']
|
|
115
321
|
return @app.call(env) if skip?(path)
|
|
116
322
|
|
|
117
323
|
token = extract_token(env)
|
|
118
|
-
|
|
119
324
|
handle_request(env, token)
|
|
120
325
|
rescue Verikloak::Error => e
|
|
121
326
|
code, status = map_error(e)
|
|
@@ -127,61 +332,17 @@ module Verikloak
|
|
|
127
332
|
|
|
128
333
|
private
|
|
129
334
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
335
|
+
# Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
|
|
336
|
+
# Exposed for tests; not part of public API.
|
|
337
|
+
def http_connection
|
|
338
|
+
@connection
|
|
178
339
|
end
|
|
179
340
|
|
|
180
341
|
# Verifies the token, stores result in Rack env, and forwards to the downstream app.
|
|
181
342
|
#
|
|
182
343
|
# @param env [Hash]
|
|
183
344
|
# @param token [String]
|
|
184
|
-
# @return [Array(Integer, Hash, Array<String>)]
|
|
345
|
+
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
185
346
|
def handle_request(env, token)
|
|
186
347
|
claims = decode_token(token)
|
|
187
348
|
env['verikloak.token'] = token
|
|
@@ -209,69 +370,6 @@ module Verikloak
|
|
|
209
370
|
token
|
|
210
371
|
end
|
|
211
372
|
|
|
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
373
|
# Converts a raised error into a `[code, http_status]` tuple for response rendering.
|
|
276
374
|
#
|
|
277
375
|
# @param error [Exception]
|
|
@@ -313,7 +411,6 @@ module Verikloak
|
|
|
313
411
|
#
|
|
314
412
|
# @param error [Exception]
|
|
315
413
|
# @return [void]
|
|
316
|
-
# @api private
|
|
317
414
|
def log_internal_error(error)
|
|
318
415
|
warn "[verikloak] Internal error: #{error.class} - #{error.message}"
|
|
319
416
|
warn error.backtrace.join("\n") if error.backtrace
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'jwt'
|
|
4
4
|
|
|
5
5
|
module Verikloak
|
|
6
|
-
# Verifies JWT tokens using a
|
|
6
|
+
# Verifies JWT tokens using a JWKs.
|
|
7
7
|
#
|
|
8
8
|
# This class validates a JWT's signature and standard claims (`iss`, `aud`, `exp`, `nbf`, etc.)
|
|
9
9
|
# using the appropriate RSA public key selected by the JWT's `kid` header.
|
|
@@ -24,17 +24,24 @@ module Verikloak
|
|
|
24
24
|
# Default clock skew tolerance in seconds.
|
|
25
25
|
DEFAULT_LEEWAY = 60
|
|
26
26
|
|
|
27
|
-
# Initializes the decoder with a
|
|
27
|
+
# Initializes the decoder with a JWKs and verification criteria.
|
|
28
28
|
#
|
|
29
29
|
# @param jwks [Array<Hash>] List of JWKs from the discovery document.
|
|
30
30
|
# @param issuer [String] Expected `iss` value in the token.
|
|
31
31
|
# @param audience [String] Expected `aud` value in the token.
|
|
32
32
|
# @param leeway [Integer] Clock skew tolerance in seconds (optional).
|
|
33
|
-
|
|
33
|
+
# @param options [Hash] Extra JWT verification options.
|
|
34
|
+
# Mirrors ruby-jwt options (e.g., :leeway, :verify_iat, :verify_expiration, :verify_not_before, :algorithms).
|
|
35
|
+
# NOTE: If both `leeway:` and `options[:leeway]` are provided, `options[:leeway]` takes precedence.
|
|
36
|
+
def initialize(jwks:, issuer:, audience:, leeway: DEFAULT_LEEWAY, options: {})
|
|
34
37
|
@jwks = jwks
|
|
35
38
|
@issuer = issuer
|
|
36
39
|
@audience = audience
|
|
40
|
+
# Keep backward compatibility; can be overridden by options[:leeway]
|
|
37
41
|
@leeway = leeway
|
|
42
|
+
# Normalize and store verification options
|
|
43
|
+
@options = symbolize_keys(options || {})
|
|
44
|
+
@options_without_leeway = @options.except(:leeway).freeze
|
|
38
45
|
|
|
39
46
|
# Build a kid-indexed hash for O(1) JWK lookup
|
|
40
47
|
@jwk_by_kid = {}
|
|
@@ -42,6 +49,7 @@ module Verikloak
|
|
|
42
49
|
kid_key = fetch_indifferent(j, 'kid')
|
|
43
50
|
@jwk_by_kid[kid_key] = j if kid_key
|
|
44
51
|
end
|
|
52
|
+
@options.freeze
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
# Decodes and verifies a JWT.
|
|
@@ -98,7 +106,7 @@ module Verikloak
|
|
|
98
106
|
kid = fetch_indifferent(header, 'kid')
|
|
99
107
|
jwk = @jwk_by_kid[kid]
|
|
100
108
|
|
|
101
|
-
raise TokenDecoderError.new("Key with kid=#{kid} not found in
|
|
109
|
+
raise TokenDecoderError.new("Key with kid=#{kid} not found in JWKs", code: 'invalid_token') unless jwk
|
|
102
110
|
|
|
103
111
|
jwk
|
|
104
112
|
end
|
|
@@ -123,8 +131,7 @@ module Verikloak
|
|
|
123
131
|
#
|
|
124
132
|
# @return [Hash]
|
|
125
133
|
def jwt_decode_options
|
|
126
|
-
{
|
|
127
|
-
# Specify allowed algorithms explicitly as an array to prevent header tampering
|
|
134
|
+
base = {
|
|
128
135
|
algorithms: ['RS256'],
|
|
129
136
|
iss: @issuer,
|
|
130
137
|
verify_iss: true,
|
|
@@ -132,9 +139,14 @@ module Verikloak
|
|
|
132
139
|
verify_aud: true,
|
|
133
140
|
verify_iat: true,
|
|
134
141
|
verify_expiration: true,
|
|
135
|
-
verify_not_before: true
|
|
136
|
-
leeway: @leeway # allow clock skew tolerance
|
|
142
|
+
verify_not_before: true
|
|
137
143
|
}
|
|
144
|
+
# options[:leeway] overrides top-level @leeway if provided
|
|
145
|
+
leeway = @options.key?(:leeway) ? @options[:leeway] : @leeway
|
|
146
|
+
merged = base.merge(leeway: leeway)
|
|
147
|
+
# Merge remaining options last (excluding :leeway which is already applied)
|
|
148
|
+
extra = @options_without_leeway
|
|
149
|
+
merged.merge(extra)
|
|
138
150
|
end
|
|
139
151
|
|
|
140
152
|
# Imports an OpenSSL::PKey::RSA public key from the given JWK.
|
|
@@ -242,5 +254,11 @@ module Verikloak
|
|
|
242
254
|
"JWT verification failed: #{error.message}"
|
|
243
255
|
end
|
|
244
256
|
end
|
|
257
|
+
|
|
258
|
+
def symbolize_keys(hash)
|
|
259
|
+
return {} unless hash.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
hash.transform_keys(&:to_sym)
|
|
262
|
+
end
|
|
245
263
|
end
|
|
246
264
|
end
|
data/lib/verikloak/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: verikloak
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -47,20 +47,26 @@ dependencies:
|
|
|
47
47
|
name: jwt
|
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
|
49
49
|
requirements:
|
|
50
|
-
- - "
|
|
50
|
+
- - ">="
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
52
|
version: '2.7'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '4.0'
|
|
53
56
|
type: :runtime
|
|
54
57
|
prerelease: false
|
|
55
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
56
59
|
requirements:
|
|
57
|
-
- - "
|
|
60
|
+
- - ">="
|
|
58
61
|
- !ruby/object:Gem::Version
|
|
59
62
|
version: '2.7'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '4.0'
|
|
60
66
|
description: |
|
|
61
67
|
Verikloak is a lightweight Ruby gem that provides JWT access token verification middleware
|
|
62
68
|
for Rack-based applications, including Rails API mode. It uses OpenID Connect discovery
|
|
63
|
-
and
|
|
69
|
+
and JWKs to securely validate tokens issued by Keycloak.
|
|
64
70
|
executables: []
|
|
65
71
|
extensions: []
|
|
66
72
|
extra_rdoc_files: []
|
|
@@ -82,7 +88,7 @@ metadata:
|
|
|
82
88
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
83
89
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
84
90
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
85
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/0.1.
|
|
91
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/0.1.3
|
|
86
92
|
rubygems_mfa_required: 'true'
|
|
87
93
|
rdoc_options: []
|
|
88
94
|
require_paths:
|
|
@@ -91,7 +97,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
91
97
|
requirements:
|
|
92
98
|
- - ">="
|
|
93
99
|
- !ruby/object:Gem::Version
|
|
94
|
-
version: '3.
|
|
100
|
+
version: '3.1'
|
|
95
101
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
102
|
requirements:
|
|
97
103
|
- - ">="
|