verikloak 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -12
- data/README.md +89 -75
- data/lib/verikloak/jwks_cache.rb +22 -11
- data/lib/verikloak/middleware.rb +377 -130
- data/lib/verikloak/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24bc2b5eb7f699c9f8bc4970a2b0b6cdca87b7c7e37d9718c51d85d8cf58a8cc
|
|
4
|
+
data.tar.gz: 439ec2fb34a67140476aff243addfaac8cb8d5ac18ef7b6915d3a9ba6dc5947d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61be82820e149b89c7e5dead4bc79aef1da0959b85ba447e7bf9d6a346efb331f378030620c2ee42d2b41e5c3e21d89346f405ad16b6264f9452fc49771fc2d4
|
|
7
|
+
data.tar.gz: 9bec7c747971154c59321505fdd2817487e09013eb8e32d3defe86a7dc38a4731c5e01d113639cd3eae1a33e9073b932dfa5d9a243b65a1609d9b3549cb48129
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2025-12-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **NEW**: `issuer` parameter for `Middleware#initialize` to optionally override the discovered issuer
|
|
14
|
+
- When provided, the configured issuer takes precedence over the OIDC discovery document's issuer
|
|
15
|
+
- This enables compatibility with `verikloak-rails` which passes `issuer` from configuration
|
|
16
|
+
- If not provided, the middleware continues to use the issuer from OIDC discovery (existing behavior)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Internal issuer handling now distinguishes between `@configured_issuer` (user-provided) and `@issuer` (discovered/effective)
|
|
20
|
+
- When `jwks_cache` is injected, discovery is only fetched once (and skipped entirely if `issuer` is provided)
|
|
21
|
+
|
|
22
|
+
## [0.2.1] - 2025-09-23
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **BREAKING**: `JwksCache` is now thread-safe with Mutex synchronization around all cache operations
|
|
26
|
+
- Middleware code organization: split large modules into focused, single-responsibility components:
|
|
27
|
+
- `SkipPathMatcher`: Path matching and normalization logic
|
|
28
|
+
- `MiddlewareAudienceResolution`: Audience resolution with dynamic callable support
|
|
29
|
+
- `MiddlewareConfiguration`: Configuration validation and logging utilities
|
|
30
|
+
- `MiddlewareDecoderCache`: LRU cache management for TokenDecoder instances
|
|
31
|
+
- `MiddlewareTokenVerification`: JWT verification and JWKs management
|
|
32
|
+
- `MiddlewareErrorMapping`: Error-to-HTTP status code mapping
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Removed duplicate method definitions that were causing code bloat
|
|
36
|
+
- Audience callable parameter detection now handles edge cases more reliably
|
|
37
|
+
- Thread-safety issues in concurrent environments resolved
|
|
38
|
+
|
|
10
39
|
## [0.2.0] - 2025-09-22
|
|
11
40
|
|
|
12
41
|
### Added
|
|
@@ -17,8 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
46
|
### Changed
|
|
18
47
|
- Update gem version to 0.2.0 to stay aligned with the rest of the Verikloak ecosystem gems.
|
|
19
48
|
|
|
20
|
-
---
|
|
21
|
-
|
|
22
49
|
## [0.1.5] - 2025-09-21
|
|
23
50
|
|
|
24
51
|
### Added
|
|
@@ -31,15 +58,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
31
58
|
### Dependencies
|
|
32
59
|
- Declare `faraday-retry` as a runtime dependency so the default HTTP connection can load the retry middleware.
|
|
33
60
|
|
|
34
|
-
---
|
|
35
|
-
|
|
36
61
|
## [0.1.4] - 2025-09-20
|
|
37
62
|
|
|
38
63
|
### Chore
|
|
39
64
|
- Bump dev dependency `rexml` to 3.4.2 (PR #15).
|
|
40
65
|
|
|
41
|
-
---
|
|
42
|
-
|
|
43
66
|
## [0.1.3] - 2025-09-15
|
|
44
67
|
|
|
45
68
|
### Changed
|
|
@@ -49,8 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
49
72
|
- Bump dev dependency `rubocop` to 1.80.2 (PR #13).
|
|
50
73
|
- Bump dev dependency `rubocop-rspec` to 3.7.0 (PR #12).
|
|
51
74
|
|
|
52
|
-
---
|
|
53
|
-
|
|
54
75
|
## [0.1.2] - 2025-08-31
|
|
55
76
|
|
|
56
77
|
### Added
|
|
@@ -63,8 +84,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
63
84
|
- Middleware: TokenDecoder instances are now cached per JWKs fetch for performance improvement.
|
|
64
85
|
- Internal: RuboCop style fixes (`HashExcept`, `HashTransformKeys`, long line splits).
|
|
65
86
|
|
|
66
|
-
---
|
|
67
|
-
|
|
68
87
|
## [0.1.1] - 2025-08-24
|
|
69
88
|
|
|
70
89
|
### Changed
|
|
@@ -72,8 +91,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
72
91
|
- Updated dependency constraints in gemspec (`json` ~> 2.6, `jwt` ~> 2.7) for better compatibility control
|
|
73
92
|
- Updated README badges (Gem version, Ruby version, downloads)
|
|
74
93
|
|
|
75
|
-
---
|
|
76
|
-
|
|
77
94
|
## [0.1.0] - 2025-08-17
|
|
78
95
|
|
|
79
96
|
### Added
|
data/README.md
CHANGED
|
@@ -41,10 +41,42 @@ Add the middleware in `config/application.rb`:
|
|
|
41
41
|
```ruby
|
|
42
42
|
config.middleware.use Verikloak::Middleware,
|
|
43
43
|
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
|
|
44
|
-
audience: "your-client-id"
|
|
45
|
-
skip_paths: ['/skip_path']
|
|
44
|
+
audience: "your-client-id"
|
|
46
45
|
```
|
|
47
46
|
|
|
47
|
+
For production environments, use environment variables:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
config.middleware.use Verikloak::Middleware,
|
|
51
|
+
discovery_url: ENV.fetch("DISCOVERY_URL"),
|
|
52
|
+
audience: ENV.fetch("CLIENT_ID"),
|
|
53
|
+
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Advanced configuration
|
|
57
|
+
|
|
58
|
+
For more complex setups, Verikloak supports additional configuration options:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
config.middleware.use Verikloak::Middleware,
|
|
62
|
+
discovery_url: ENV.fetch("DISCOVERY_URL"),
|
|
63
|
+
audience: ENV.fetch("CLIENT_ID"),
|
|
64
|
+
issuer: "https://custom.issuer.example.com/realms/myrealm", # Optional: override discovered issuer
|
|
65
|
+
skip_paths: ['/', '/health', '/public/*', '/rails/*'],
|
|
66
|
+
leeway: 30, # Allow 30 seconds clock skew
|
|
67
|
+
token_env_key: "rack.session.token", # Custom token storage key
|
|
68
|
+
user_env_key: "rack.session.claims", # Custom user claims storage key
|
|
69
|
+
realm: "my-api", # Custom realm for WWW-Authenticate header
|
|
70
|
+
logger: Rails.logger # Logger for internal errors
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Additional middleware options:**
|
|
74
|
+
|
|
75
|
+
- `token_env_key` (default: `"verikloak.token"`) — where the raw JWT is stored in the Rack env
|
|
76
|
+
- `user_env_key` (default: `"verikloak.user"`) — where decoded claims are stored
|
|
77
|
+
- `realm` (default: `"verikloak"`) — value used in the `WWW-Authenticate` header for 401 responses
|
|
78
|
+
- `logger` — an object responding to `error` (and optionally `debug`) that receives unexpected 500-level failures
|
|
79
|
+
|
|
48
80
|
#### Handling Authentication Failures
|
|
49
81
|
|
|
50
82
|
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.
|
|
@@ -84,40 +116,25 @@ All Verikloak errors inherit from `Verikloak::Error`:
|
|
|
84
116
|
- `Verikloak::JwksCacheError` – JWKs fetch/parse/cache (`503 Service Unavailable`)
|
|
85
117
|
- `Verikloak::MiddlewareError` – header/infra issues surfaced by the middleware (usually `401`, sometimes `503`)
|
|
86
118
|
|
|
87
|
-
|
|
119
|
+
### Standalone Rack app
|
|
88
120
|
|
|
89
121
|
```ruby
|
|
90
|
-
config.
|
|
91
|
-
|
|
92
|
-
audience: ENV.fetch("CLIENT_ID"),
|
|
93
|
-
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
94
|
-
```
|
|
95
|
-
This makes the configuration secure and flexible across environments.
|
|
96
|
-
|
|
97
|
-
#### Advanced middleware options
|
|
98
|
-
|
|
99
|
-
`Verikloak::Middleware` exposes a few optional knobs that help integrate with
|
|
100
|
-
different Rack stacks:
|
|
122
|
+
# config.ru example for a standalone Rack app
|
|
123
|
+
require "verikloak"
|
|
101
124
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- `logger` — an object responding to `error` (and optionally `debug`) that receives unexpected 500-level failures
|
|
125
|
+
use Verikloak::Middleware,
|
|
126
|
+
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
|
|
127
|
+
audience: "my-client-id"
|
|
106
128
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
token_env_key: "rack.session.token",
|
|
112
|
-
user_env_key: "rack.session.claims",
|
|
113
|
-
realm: "my-api",
|
|
114
|
-
logger: Rails.logger
|
|
129
|
+
run ->(env) {
|
|
130
|
+
user = env["verikloak.user"] # Decoded JWT claims hash (if token is valid)
|
|
131
|
+
[200, { "Content-Type" => "application/json" }, [user.to_json]]
|
|
132
|
+
}
|
|
115
133
|
```
|
|
116
134
|
|
|
117
135
|
### Accessing claims in controllers
|
|
118
136
|
|
|
119
|
-
Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment
|
|
120
|
-
You can access them in any Rails controller:
|
|
137
|
+
Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment:
|
|
121
138
|
|
|
122
139
|
```ruby
|
|
123
140
|
class Api::V1::NotesController < ApplicationController
|
|
@@ -131,22 +148,6 @@ class Api::V1::NotesController < ApplicationController
|
|
|
131
148
|
end
|
|
132
149
|
```
|
|
133
150
|
|
|
134
|
-
### Standalone Rack app
|
|
135
|
-
|
|
136
|
-
```ruby
|
|
137
|
-
# config.ru example for a standalone Rack app
|
|
138
|
-
require "verikloak"
|
|
139
|
-
|
|
140
|
-
use Verikloak::Middleware,
|
|
141
|
-
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
|
|
142
|
-
audience: "my-client-id"
|
|
143
|
-
|
|
144
|
-
run ->(env) {
|
|
145
|
-
user = env["verikloak.user"] # Decoded JWT claims hash (if token is valid)
|
|
146
|
-
[200, { "Content-Type" => "application/json" }, [user.to_json]]
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
151
|
## How It Works
|
|
151
152
|
|
|
152
153
|
1. Extracts the `Authorization: Bearer <token>` header
|
|
@@ -194,26 +195,7 @@ Verikloak returns JSON error responses in a consistent format with structured er
|
|
|
194
195
|
}
|
|
195
196
|
```
|
|
196
197
|
|
|
197
|
-
|
|
198
|
-
{
|
|
199
|
-
"error": "jwks_parse_failed",
|
|
200
|
-
"message": "Failed to parse JWKs"
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
```json
|
|
205
|
-
{
|
|
206
|
-
"error": "discovery_metadata_fetch_failed",
|
|
207
|
-
"message": "Failed to fetch OIDC discovery document"
|
|
208
|
-
}
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
```json
|
|
212
|
-
{
|
|
213
|
-
"error": "discovery_metadata_invalid",
|
|
214
|
-
"message": "Failed to parse OIDC discovery document"
|
|
215
|
-
}
|
|
216
|
-
```
|
|
198
|
+
For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
|
|
217
199
|
|
|
218
200
|
### Error Types
|
|
219
201
|
|
|
@@ -223,36 +205,40 @@ Verikloak returns JSON error responses in a consistent format with structured er
|
|
|
223
205
|
| `expired_token` | 401 Unauthorized | The token has expired |
|
|
224
206
|
| `missing_authorization_header` | 401 Unauthorized | The `Authorization` header is missing |
|
|
225
207
|
| `invalid_authorization_header` | 401 Unauthorized | The `Authorization` header format is invalid |
|
|
226
|
-
| `unsupported_algorithm` | 401 Unauthorized | The token
|
|
208
|
+
| `unsupported_algorithm` | 401 Unauthorized | The token's signing algorithm is not supported |
|
|
227
209
|
| `invalid_signature` | 401 Unauthorized | The token signature could not be verified |
|
|
228
210
|
| `invalid_issuer` | 401 Unauthorized | Invalid `iss` claim |
|
|
229
211
|
| `invalid_audience` | 401 Unauthorized | Invalid `aud` claim |
|
|
230
212
|
| `not_yet_valid` | 401 Unauthorized | The token is not yet valid (`nbf` in the future) |
|
|
231
|
-
| `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch JWKs
|
|
232
|
-
| `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKs
|
|
213
|
+
| `jwks_fetch_failed` | 503 Service Unavailable | Failed to fetch JWKs |
|
|
214
|
+
| `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKs |
|
|
233
215
|
| `jwks_cache_miss` | 503 Service Unavailable | JWKs cache is empty (e.g., 304 Not Modified without prior cache) |
|
|
234
216
|
| `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
|
|
235
|
-
| `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document
|
|
217
|
+
| `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
|
|
236
218
|
| `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
|
|
237
219
|
| `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
|
|
238
220
|
|
|
239
221
|
> **Note:** The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
|
|
240
222
|
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
241
223
|
|
|
242
|
-
For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
|
|
243
|
-
|
|
244
224
|
## Configuration Options
|
|
245
225
|
|
|
246
226
|
| Key | Required | Description |
|
|
247
227
|
| --------------- | -------- | ------------------------------------------- |
|
|
248
228
|
| `discovery_url` | Yes | Full URL to your realm's OIDC discovery doc |
|
|
249
229
|
| `audience` | Yes | Your client ID (checked against `aud`). Accepts a String or callable returning a String/Array per request. |
|
|
230
|
+
| `issuer` | No | Optional override for the expected `iss` claim; defaults to the discovery `issuer`. |
|
|
250
231
|
| `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
|
|
251
232
|
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
252
233
|
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
253
234
|
| `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
|
|
254
235
|
| `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. |
|
|
236
|
+
| `decoder_cache_limit` | No | Maximum number of `TokenDecoder` instances retained per middleware. Defaults to `128`. Set to `0` to disable caching or `nil` for an unlimited cache. |
|
|
255
237
|
| `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
|
|
238
|
+
| `token_env_key` | No | Rack env key for the raw JWT. Defaults to `verikloak.token`. |
|
|
239
|
+
| `user_env_key` | No | Rack env key for decoded claims. Defaults to `verikloak.user`. |
|
|
240
|
+
| `realm` | No | Value used in the `WWW-Authenticate` header. Defaults to `verikloak`. |
|
|
241
|
+
| `logger` | No | Logger for unexpected internal failures (responds to `error`, optionally `debug`). |
|
|
256
242
|
|
|
257
243
|
#### Option: `skip_paths`
|
|
258
244
|
|
|
@@ -272,11 +258,11 @@ skip_paths: ['/', '/health', '/rails/*', '/public/src']
|
|
|
272
258
|
Paths **not matched** by any `skip_paths` entry will require a valid JWT.
|
|
273
259
|
|
|
274
260
|
**Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
|
|
275
|
-
Internally, `*` expands to match nested paths, so patterns like `/rails/*` are valid. This differs from regex
|
|
261
|
+
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.
|
|
276
262
|
|
|
277
263
|
#### Option: `audience`
|
|
278
264
|
|
|
279
|
-
The `audience` option may be either a static String or any callable object (Proc, lambda, object responding to `#call`). When a callable is provided it receives the Rack `env` and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
|
|
265
|
+
The `audience` option may be either a static String or any callable object (Proc, lambda, object responding to `#call`). When a callable is provided it receives the Rack `env` (either as a positional argument or keyword `env:`) and can return a different audience for each request. This is useful when a single gateway serves multiple downstream clients:
|
|
280
266
|
|
|
281
267
|
```ruby
|
|
282
268
|
Verikloak::Middleware.new(app,
|
|
@@ -342,11 +328,39 @@ config.middleware.use Verikloak::Middleware,
|
|
|
342
328
|
- `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
|
|
343
329
|
- If both are set, `token_verify_options[:leeway]` takes precedence.
|
|
344
330
|
|
|
345
|
-
|
|
331
|
+
## Performance & Caching
|
|
332
|
+
|
|
333
|
+
#### Decoder cache & performance
|
|
346
334
|
|
|
347
335
|
Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
|
|
348
|
-
them on every request.
|
|
349
|
-
|
|
336
|
+
them on every request. The cache behaves like an LRU with a configurable size (`decoder_cache_limit`)
|
|
337
|
+
so long-running processes do not accumulate decoders for one-off audiences. When the underlying
|
|
338
|
+
JWK set rotates, the middleware now clears the cache to drop decoders that point at stale keys.
|
|
339
|
+
|
|
340
|
+
Set `decoder_cache_limit` to `0` if you prefer to construct a fresh decoder every time, or `nil`
|
|
341
|
+
when you want the cache to grow without bounds (e.g., in short-lived jobs).
|
|
342
|
+
|
|
343
|
+
#### Sharing caches across verikloak gems
|
|
344
|
+
|
|
345
|
+
When combining `verikloak` with companion gems (such as `verikloak-rails`, `verikloak-bff`, etc.),
|
|
346
|
+
reusing infrastructure objects avoids redundant HTTP calls:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
connection = Verikloak::HTTP.default_connection
|
|
350
|
+
jwks_cache = Verikloak::JwksCache.new(jwks_uri: ENV['JWKS_URI'], connection: connection)
|
|
351
|
+
|
|
352
|
+
use Verikloak::Middleware,
|
|
353
|
+
discovery_url: ENV['DISCOVERY_URL'],
|
|
354
|
+
audience: ->(env) { env['verikloak.audience'] },
|
|
355
|
+
connection: connection,
|
|
356
|
+
jwks_cache: jwks_cache
|
|
357
|
+
|
|
358
|
+
# Rails initializer or service object can now reuse `connection` and `jwks_cache`
|
|
359
|
+
# when configuring other verikloak-* gems.
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Sharing the Faraday connection keeps retry/time-out policies consistent, while a single `JwksCache`
|
|
363
|
+
ensures all middleware layers refresh keys in unison.
|
|
350
364
|
|
|
351
365
|
## Architecture
|
|
352
366
|
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -50,6 +50,7 @@ module Verikloak
|
|
|
50
50
|
@etag = nil
|
|
51
51
|
@fetched_at = nil
|
|
52
52
|
@max_age = nil
|
|
53
|
+
@mutex = Mutex.new
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
# Fetches the JWKs and updates the in-memory cache.
|
|
@@ -61,27 +62,31 @@ module Verikloak
|
|
|
61
62
|
# @return [Array<Hash>] the cached JWKs after fetch/revalidation
|
|
62
63
|
# @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
|
|
63
64
|
def fetch!
|
|
64
|
-
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return @cached_keys if fresh_by_ttl_locked?
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
with_error_handling do
|
|
69
|
+
# Build conditional request headers (ETag-based)
|
|
70
|
+
headers = build_conditional_headers
|
|
71
|
+
# Perform HTTP GET request
|
|
72
|
+
response = @connection.get(@jwks_uri, nil, headers)
|
|
73
|
+
# Handle HTTP response according to status code
|
|
74
|
+
handle_response(response)
|
|
75
|
+
end
|
|
73
76
|
end
|
|
74
77
|
end
|
|
75
78
|
|
|
76
79
|
# Returns the last cached JWKs without performing a network request.
|
|
77
80
|
# @return [Array<Hash>, nil] cached keys, or nil if never fetched
|
|
78
81
|
def cached
|
|
79
|
-
@cached_keys
|
|
82
|
+
@mutex.synchronize { @cached_keys }
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# Timestamp of the last successful fetch or revalidation.
|
|
83
86
|
# @return [Time, nil]
|
|
84
|
-
|
|
87
|
+
def fetched_at
|
|
88
|
+
@mutex.synchronize { @fetched_at }
|
|
89
|
+
end
|
|
85
90
|
|
|
86
91
|
# Injected Faraday connection (for testing and shared config across the gem)
|
|
87
92
|
# @return [Faraday::Connection]
|
|
@@ -94,7 +99,7 @@ module Verikloak
|
|
|
94
99
|
#
|
|
95
100
|
# @return [Boolean]
|
|
96
101
|
def stale?
|
|
97
|
-
!
|
|
102
|
+
@mutex.synchronize { !fresh_by_ttl_locked? }
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
# @api private
|
|
@@ -125,6 +130,12 @@ module Verikloak
|
|
|
125
130
|
# True when cached keys are still fresh per `Cache-Control: max-age`.
|
|
126
131
|
# @return [Boolean]
|
|
127
132
|
def fresh_by_ttl?
|
|
133
|
+
@mutex.synchronize { fresh_by_ttl_locked? }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def fresh_by_ttl_locked?
|
|
128
139
|
return false unless @cached_keys && @fetched_at && @max_age
|
|
129
140
|
|
|
130
141
|
(Time.now - @fetched_at) < @max_age
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -86,6 +86,299 @@ module Verikloak
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# @api private
|
|
90
|
+
#
|
|
91
|
+
# Internal mixin for audience resolution with dynamic callable support.
|
|
92
|
+
# Handles various callable signatures and parameter detection.
|
|
93
|
+
module MiddlewareAudienceResolution
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Resolves the expected audience for the current request.
|
|
97
|
+
#
|
|
98
|
+
# @param env [Hash] Rack environment.
|
|
99
|
+
# @return [String, Array<String>] The expected audience value.
|
|
100
|
+
# @raise [MiddlewareError] when the resolved audience is blank.
|
|
101
|
+
def resolve_audience(env)
|
|
102
|
+
source = @audience_source
|
|
103
|
+
value = if source.respond_to?(:call)
|
|
104
|
+
callable = source
|
|
105
|
+
call_with_optional_env(callable, env)
|
|
106
|
+
else
|
|
107
|
+
source
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.nil?
|
|
111
|
+
|
|
112
|
+
if value.is_a?(Array)
|
|
113
|
+
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.empty?
|
|
114
|
+
|
|
115
|
+
return value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
normalized = value.to_s
|
|
119
|
+
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if normalized.empty?
|
|
120
|
+
|
|
121
|
+
normalized
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Invokes the audience callable, passing the Rack env only when required.
|
|
125
|
+
# Falls back to a zero-argument invocation if the callable raises
|
|
126
|
+
# `ArgumentError` due to an unexpected argument.
|
|
127
|
+
#
|
|
128
|
+
# @param callable [#call] Audience resolver callable.
|
|
129
|
+
# @param env [Hash] Rack environment.
|
|
130
|
+
# @param arity [Integer, nil] Callable arity when known, nil otherwise.
|
|
131
|
+
# @return [Object] Audience value returned by the callable.
|
|
132
|
+
# @raise [ArgumentError] when the callable raises for reasons other than arity mismatch.
|
|
133
|
+
def call_with_optional_env(callable, env)
|
|
134
|
+
params = callable_parameters(callable)
|
|
135
|
+
|
|
136
|
+
invocation_chain(params).each do |strategy|
|
|
137
|
+
return strategy.call(callable, env)
|
|
138
|
+
rescue ArgumentError => e
|
|
139
|
+
raise unless wrong_arity_error?(e)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
callable.call
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns true when the ArgumentError message indicates a wrong arity.
|
|
146
|
+
#
|
|
147
|
+
# @param error [ArgumentError]
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def wrong_arity_error?(error)
|
|
150
|
+
error.message.include?('wrong number of arguments')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Extracts parameter information from a callable's call method.
|
|
154
|
+
#
|
|
155
|
+
# @param callable [#call] The callable object to inspect.
|
|
156
|
+
# @return [Array<Array>, nil] Parameter information as returned by Method#parameters,
|
|
157
|
+
# or nil if the method cannot be resolved.
|
|
158
|
+
def callable_parameters(callable)
|
|
159
|
+
callable.method(:call).parameters
|
|
160
|
+
rescue NameError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds a chain of invocation strategies based on callable parameters.
|
|
165
|
+
#
|
|
166
|
+
# @param params [Array<Array>, nil] Parameter information from Method#parameters.
|
|
167
|
+
# @return [Array<Proc>] Ordered array of lambda strategies to try when calling the callable.
|
|
168
|
+
def invocation_chain(params)
|
|
169
|
+
strategies = []
|
|
170
|
+
|
|
171
|
+
if params.nil?
|
|
172
|
+
# When parameters are unknown, try strategies in safe order:
|
|
173
|
+
# 1. Try with positional argument first (most common)
|
|
174
|
+
# 2. Try with no arguments as fallback
|
|
175
|
+
strategies << ->(callable, env) { callable.call(env) }
|
|
176
|
+
strategies << ->(callable, _env) { callable.call }
|
|
177
|
+
else
|
|
178
|
+
# When parameters are known, try most specific to least specific
|
|
179
|
+
strategies << ->(callable, env) { callable.call(env: env) } if accepts_keyword_env?(params)
|
|
180
|
+
strategies << ->(callable, env) { callable.call(env) } if accepts_positional_env?(params)
|
|
181
|
+
strategies << ->(callable, _env) { callable.call } if accepts_zero_arguments?(params)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
strategies
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Determines if a callable accepts keyword arguments, specifically env: parameter.
|
|
188
|
+
#
|
|
189
|
+
# @param params [Array<Array>, nil] Parameter information from Method#parameters.
|
|
190
|
+
# @return [Boolean] true if the callable accepts keyword arguments including env.
|
|
191
|
+
def accepts_keyword_env?(params)
|
|
192
|
+
return false if params.nil?
|
|
193
|
+
|
|
194
|
+
params.any? do |type, name|
|
|
195
|
+
type == :keyrest ||
|
|
196
|
+
(%i[keyreq key].include?(type) && (name.nil? || name == :env))
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Determines if a callable accepts positional arguments.
|
|
201
|
+
#
|
|
202
|
+
# @param params [Array<Array>, nil] Parameter information from Method#parameters.
|
|
203
|
+
# @return [Boolean] true if the callable accepts positional arguments.
|
|
204
|
+
def accepts_positional_env?(params)
|
|
205
|
+
return false if params.nil?
|
|
206
|
+
|
|
207
|
+
params.any? { |type, _| %i[req opt rest].include?(type) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Determines if a callable accepts zero arguments (no required parameters).
|
|
211
|
+
#
|
|
212
|
+
# @param params [Array<Array>, nil] Parameter information from Method#parameters.
|
|
213
|
+
# @return [Boolean] true if the callable can be called with no arguments.
|
|
214
|
+
def accepts_zero_arguments?(params)
|
|
215
|
+
return false if params.nil?
|
|
216
|
+
|
|
217
|
+
# Only accepts zero arguments if parameters are empty
|
|
218
|
+
# or all parameters are optional/keyword/blocks
|
|
219
|
+
params.empty? || params.all? { |type, _| %i[opt key keyrest block].include?(type) }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @api private
|
|
224
|
+
#
|
|
225
|
+
# Internal mixin for configuration validation and logging utilities.
|
|
226
|
+
# Extracted to keep the main Middleware class focused and under line limits.
|
|
227
|
+
module MiddlewareConfiguration
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
# Validates and normalizes the decoder cache limit configuration.
|
|
231
|
+
#
|
|
232
|
+
# @param limit [Integer, nil] The cache limit value to normalize.
|
|
233
|
+
# @return [Integer, nil] The normalized limit, or nil if no limit.
|
|
234
|
+
# @raise [ArgumentError] if the limit is negative or invalid.
|
|
235
|
+
def normalize_decoder_cache_limit(limit)
|
|
236
|
+
return nil if limit.nil?
|
|
237
|
+
|
|
238
|
+
value = Integer(limit)
|
|
239
|
+
raise ArgumentError, 'decoder_cache_limit must be zero or positive' if value.negative?
|
|
240
|
+
|
|
241
|
+
value
|
|
242
|
+
rescue ArgumentError, TypeError
|
|
243
|
+
raise ArgumentError, 'decoder_cache_limit must be zero or positive'
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Validates and normalizes environment key configuration.
|
|
247
|
+
#
|
|
248
|
+
# @param value [String, #to_s] The environment key to normalize.
|
|
249
|
+
# @param option_name [String] The name of the option for error messages.
|
|
250
|
+
# @return [String] The normalized environment key.
|
|
251
|
+
# @raise [ArgumentError] if the key is blank after normalization.
|
|
252
|
+
def normalize_env_key(value, option_name)
|
|
253
|
+
normalized = value.to_s.strip
|
|
254
|
+
raise ArgumentError, "#{option_name} cannot be blank" if normalized.empty?
|
|
255
|
+
|
|
256
|
+
normalized
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Validates and normalizes the realm configuration.
|
|
260
|
+
#
|
|
261
|
+
# @param value [String, #to_s, nil] The realm value to normalize.
|
|
262
|
+
# @return [String] The normalized realm, or DEFAULT_REALM if nil.
|
|
263
|
+
# @raise [ArgumentError] if the realm is blank after normalization.
|
|
264
|
+
def normalize_realm(value)
|
|
265
|
+
return DEFAULT_REALM if value.nil?
|
|
266
|
+
|
|
267
|
+
normalized = value.to_s.strip
|
|
268
|
+
raise ArgumentError, 'realm cannot be blank' if normalized.empty?
|
|
269
|
+
|
|
270
|
+
normalized
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Checks if a logger instance is available and responds to logging methods.
|
|
274
|
+
#
|
|
275
|
+
# @return [Boolean] true if a logger is available and can log messages.
|
|
276
|
+
def logger_available?
|
|
277
|
+
return false unless @logger
|
|
278
|
+
|
|
279
|
+
@logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Logs a message and backtrace using the configured logger.
|
|
283
|
+
#
|
|
284
|
+
# @param message [String] The primary error message to log.
|
|
285
|
+
# @param backtrace [String, nil] The backtrace information to log.
|
|
286
|
+
# @return [void]
|
|
287
|
+
def log_with_logger(message, backtrace)
|
|
288
|
+
log_message(@logger, message)
|
|
289
|
+
log_backtrace(@logger, backtrace)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Logs a message using the most appropriate logger method.
|
|
293
|
+
#
|
|
294
|
+
# @param logger [Logger] The logger instance to use.
|
|
295
|
+
# @param message [String] The message to log.
|
|
296
|
+
# @return [void]
|
|
297
|
+
def log_message(logger, message)
|
|
298
|
+
if logger.respond_to?(:error)
|
|
299
|
+
logger.error(message)
|
|
300
|
+
elsif logger.respond_to?(:warn)
|
|
301
|
+
logger.warn(message)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Logs backtrace information using the most appropriate logger method.
|
|
306
|
+
#
|
|
307
|
+
# @param logger [Logger] The logger instance to use.
|
|
308
|
+
# @param backtrace [String, nil] The backtrace information to log.
|
|
309
|
+
# @return [void]
|
|
310
|
+
def log_backtrace(logger, backtrace)
|
|
311
|
+
return unless backtrace
|
|
312
|
+
|
|
313
|
+
if logger.respond_to?(:debug)
|
|
314
|
+
logger.debug(backtrace)
|
|
315
|
+
elsif logger.respond_to?(:error)
|
|
316
|
+
logger.error(backtrace)
|
|
317
|
+
elsif logger.respond_to?(:warn)
|
|
318
|
+
logger.warn(backtrace)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# @api private
|
|
324
|
+
#
|
|
325
|
+
# Internal mixin for decoder cache management with LRU eviction.
|
|
326
|
+
# Handles TokenDecoder instance caching and cleanup.
|
|
327
|
+
module MiddlewareDecoderCache
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
# Stores a decoder in the cache and updates access order if tracking is enabled.
|
|
331
|
+
#
|
|
332
|
+
# @param cache_key [String] The cache key for the decoder
|
|
333
|
+
# @param decoder [TokenDecoder] The decoder instance to cache
|
|
334
|
+
# @return [TokenDecoder] The cached decoder instance
|
|
335
|
+
def store_decoder_cache(cache_key, decoder)
|
|
336
|
+
@decoder_cache[cache_key] = decoder
|
|
337
|
+
touch_decoder_cache(cache_key) if track_decoder_order?
|
|
338
|
+
decoder
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Prunes the decoder cache to stay within the configured limit.
|
|
342
|
+
# Removes the oldest entries when the cache size exceeds the limit.
|
|
343
|
+
#
|
|
344
|
+
# @return [void]
|
|
345
|
+
def prune_decoder_cache_if_needed
|
|
346
|
+
return unless track_decoder_order?
|
|
347
|
+
|
|
348
|
+
while @decoder_cache_order.length >= @decoder_cache_limit
|
|
349
|
+
oldest = @decoder_cache_order.shift
|
|
350
|
+
@decoder_cache.delete(oldest)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Updates the access order for a cache entry to mark it as recently used.
|
|
355
|
+
# Moves the cache key to the end of the order queue for LRU tracking.
|
|
356
|
+
#
|
|
357
|
+
# @param cache_key [String] The cache key to mark as recently accessed
|
|
358
|
+
# @return [void]
|
|
359
|
+
def touch_decoder_cache(cache_key)
|
|
360
|
+
@decoder_cache_order.delete(cache_key)
|
|
361
|
+
@decoder_cache_order << cache_key
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Checks if decoder cache order tracking is enabled.
|
|
365
|
+
# Returns true if cache limit is set and positive.
|
|
366
|
+
#
|
|
367
|
+
# @return [Boolean] true if order tracking is enabled
|
|
368
|
+
def track_decoder_order?
|
|
369
|
+
@decoder_cache_limit&.positive?
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Clears all cached decoder instances and order tracking.
|
|
373
|
+
# Removes all entries from both the cache and order queue.
|
|
374
|
+
#
|
|
375
|
+
# @return [void]
|
|
376
|
+
def clear_decoder_cache
|
|
377
|
+
@decoder_cache.clear
|
|
378
|
+
@decoder_cache_order.clear
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
89
382
|
# @api private
|
|
90
383
|
#
|
|
91
384
|
# Internal mixin for JWT verification and discovery/JWKs management.
|
|
@@ -108,6 +401,9 @@ module Verikloak
|
|
|
108
401
|
|
|
109
402
|
# Returns a cached TokenDecoder instance for current inputs.
|
|
110
403
|
# Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
|
|
404
|
+
#
|
|
405
|
+
# @param audience [String, #call] The audience to create a decoder for
|
|
406
|
+
# @return [TokenDecoder] A decoder instance for the given audience
|
|
111
407
|
def decoder_for(audience)
|
|
112
408
|
keys = @jwks_cache.cached
|
|
113
409
|
fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
|
|
@@ -119,13 +415,23 @@ module Verikloak
|
|
|
119
415
|
fetched_at
|
|
120
416
|
].hash
|
|
121
417
|
@mutex.synchronize do
|
|
122
|
-
@decoder_cache[cache_key]
|
|
418
|
+
if (decoder = @decoder_cache[cache_key])
|
|
419
|
+
touch_decoder_cache(cache_key) if track_decoder_order?
|
|
420
|
+
return decoder
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
decoder = TokenDecoder.new(
|
|
123
424
|
jwks: keys,
|
|
124
425
|
issuer: @issuer,
|
|
125
426
|
audience: audience,
|
|
126
427
|
leeway: @leeway,
|
|
127
428
|
options: @token_verify_options
|
|
128
429
|
)
|
|
430
|
+
|
|
431
|
+
return decoder if @decoder_cache_limit&.zero?
|
|
432
|
+
|
|
433
|
+
prune_decoder_cache_if_needed
|
|
434
|
+
store_decoder_cache(cache_key, decoder)
|
|
129
435
|
end
|
|
130
436
|
end
|
|
131
437
|
|
|
@@ -141,8 +447,9 @@ module Verikloak
|
|
|
141
447
|
# Decodes and verifies the JWT using the cached JWKs. On certain verification
|
|
142
448
|
# failures (e.g., key rotation), it refreshes the JWKs and retries once.
|
|
143
449
|
#
|
|
144
|
-
# @param
|
|
145
|
-
# @
|
|
450
|
+
# @param env [Hash] The Rack environment hash
|
|
451
|
+
# @param token [String] The JWT token to decode and verify
|
|
452
|
+
# @return [Hash] Decoded JWT claims
|
|
146
453
|
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
147
454
|
def decode_token(env, token)
|
|
148
455
|
ensure_jwks_cache!
|
|
@@ -169,73 +476,6 @@ module Verikloak
|
|
|
169
476
|
end
|
|
170
477
|
end
|
|
171
478
|
|
|
172
|
-
# Resolves the expected audience for the current request.
|
|
173
|
-
#
|
|
174
|
-
# @param env [Hash] Rack environment.
|
|
175
|
-
# @return [String, Array<String>] The expected audience value.
|
|
176
|
-
# @raise [MiddlewareError] when the resolved audience is blank.
|
|
177
|
-
def resolve_audience(env)
|
|
178
|
-
source = @audience_source
|
|
179
|
-
value = if source.respond_to?(:call)
|
|
180
|
-
callable = source
|
|
181
|
-
arity = callable.respond_to?(:arity) ? callable.arity : safe_callable_arity(callable)
|
|
182
|
-
call_with_optional_env(callable, env, arity)
|
|
183
|
-
else
|
|
184
|
-
source
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.nil?
|
|
188
|
-
|
|
189
|
-
if value.is_a?(Array)
|
|
190
|
-
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if value.empty?
|
|
191
|
-
|
|
192
|
-
return value
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
normalized = value.to_s
|
|
196
|
-
raise MiddlewareError.new('Audience is blank for the request', code: 'invalid_audience') if normalized.empty?
|
|
197
|
-
|
|
198
|
-
normalized
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# Invokes the audience callable, passing the Rack env only when required.
|
|
202
|
-
# Falls back to a zero-argument invocation if the callable raises
|
|
203
|
-
# `ArgumentError` due to an unexpected argument.
|
|
204
|
-
#
|
|
205
|
-
# @param callable [#call] Audience resolver callable.
|
|
206
|
-
# @param env [Hash] Rack environment.
|
|
207
|
-
# @param arity [Integer, nil] Callable arity when known, nil otherwise.
|
|
208
|
-
# @return [Object] Audience value returned by the callable.
|
|
209
|
-
# @raise [ArgumentError] when the callable raises for reasons other than arity mismatch.
|
|
210
|
-
def call_with_optional_env(callable, env, arity)
|
|
211
|
-
return callable.call if arity&.zero?
|
|
212
|
-
|
|
213
|
-
callable.call(env)
|
|
214
|
-
rescue ArgumentError => e
|
|
215
|
-
raise unless arity.nil? && wrong_arity_error?(e)
|
|
216
|
-
|
|
217
|
-
callable.call
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
# Safely obtains a callable's arity, returning nil when `#method(:call)`
|
|
221
|
-
# cannot be resolved (e.g., BasicObject-based objects).
|
|
222
|
-
#
|
|
223
|
-
# @param callable [#call]
|
|
224
|
-
# @return [Integer, nil]
|
|
225
|
-
def safe_callable_arity(callable)
|
|
226
|
-
callable.method(:call).arity
|
|
227
|
-
rescue NameError
|
|
228
|
-
nil
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Returns true when the ArgumentError message indicates a wrong arity.
|
|
232
|
-
#
|
|
233
|
-
# @param error [ArgumentError]
|
|
234
|
-
# @return [Boolean]
|
|
235
|
-
def wrong_arity_error?(error)
|
|
236
|
-
error.message.include?('wrong number of arguments')
|
|
237
|
-
end
|
|
238
|
-
|
|
239
479
|
# Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
|
|
240
480
|
# This method is thread-safe.
|
|
241
481
|
#
|
|
@@ -246,14 +486,21 @@ module Verikloak
|
|
|
246
486
|
# @raise [Verikloak::DiscoveryError, Verikloak::JwksCacheError, Verikloak::MiddlewareError]
|
|
247
487
|
def ensure_jwks_cache!
|
|
248
488
|
@mutex.synchronize do
|
|
489
|
+
previous_keys_id = cached_keys_identity(@jwks_cache)
|
|
249
490
|
if @jwks_cache.nil?
|
|
250
491
|
config = @discovery.fetch!
|
|
251
|
-
|
|
492
|
+
# Use configured issuer if provided, otherwise use discovered issuer
|
|
493
|
+
@issuer = @configured_issuer || config['issuer']
|
|
252
494
|
jwks_uri = config['jwks_uri']
|
|
253
495
|
@jwks_cache = JwksCache.new(jwks_uri: jwks_uri, connection: @connection)
|
|
496
|
+
elsif @configured_issuer.nil? && @issuer.nil?
|
|
497
|
+
# If jwks_cache was injected but no issuer configured and not yet discovered, fetch discovery to set issuer
|
|
498
|
+
config = @discovery.fetch!
|
|
499
|
+
@issuer = config['issuer']
|
|
254
500
|
end
|
|
255
501
|
|
|
256
502
|
@jwks_cache.fetch!
|
|
503
|
+
purge_decoder_cache_if_keys_changed(previous_keys_id)
|
|
257
504
|
end
|
|
258
505
|
rescue Verikloak::DiscoveryError, Verikloak::JwksCacheError => e
|
|
259
506
|
# Re-raise so that specific error codes can be mapped in the middleware
|
|
@@ -261,6 +508,33 @@ module Verikloak
|
|
|
261
508
|
rescue StandardError => e
|
|
262
509
|
raise MiddlewareError.new("Failed to initialize JWKs cache: #{e.message}", code: 'jwks_fetch_failed')
|
|
263
510
|
end
|
|
511
|
+
|
|
512
|
+
# Purges the decoder cache if the JWKs have changed since last check.
|
|
513
|
+
# Compares key set identity to detect key rotation and invalidate cached decoders.
|
|
514
|
+
#
|
|
515
|
+
# @param previous_keys_id [String, nil] The previous JWKs identity hash
|
|
516
|
+
# @return [void]
|
|
517
|
+
def purge_decoder_cache_if_keys_changed(previous_keys_id)
|
|
518
|
+
current_id = cached_keys_identity(@jwks_cache)
|
|
519
|
+
if (@last_cached_keys_id && current_id && @last_cached_keys_id != current_id) ||
|
|
520
|
+
(previous_keys_id && current_id && previous_keys_id != current_id)
|
|
521
|
+
clear_decoder_cache
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
@last_cached_keys_id = current_id if current_id
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Generates a unique identity hash for the current JWKs set.
|
|
528
|
+
# Used to detect changes in the key set for cache invalidation.
|
|
529
|
+
#
|
|
530
|
+
# @param cache [JwksCache] The JWKs cache instance
|
|
531
|
+
# @return [String, nil] A hash representing the current key set identity
|
|
532
|
+
def cached_keys_identity(cache)
|
|
533
|
+
return unless cache.respond_to?(:cached)
|
|
534
|
+
|
|
535
|
+
keys = cache.cached
|
|
536
|
+
keys&.__id__
|
|
537
|
+
end
|
|
264
538
|
end
|
|
265
539
|
|
|
266
540
|
# @api private
|
|
@@ -285,13 +559,17 @@ module Verikloak
|
|
|
285
559
|
|
|
286
560
|
private
|
|
287
561
|
|
|
288
|
-
#
|
|
562
|
+
# Determines if an error code should result in a 403 Forbidden response.
|
|
563
|
+
#
|
|
564
|
+
# @param code [String, nil] The error code to check
|
|
289
565
|
# @return [Boolean] true if the error should be treated as a 403 Forbidden
|
|
290
566
|
def forbidden?(code)
|
|
291
567
|
code == 'forbidden'
|
|
292
568
|
end
|
|
293
569
|
|
|
294
|
-
#
|
|
570
|
+
# Determines if an error code belongs to authentication-related errors.
|
|
571
|
+
#
|
|
572
|
+
# @param code [String, nil] The error code to check
|
|
295
573
|
# @return [Boolean] true if the error belongs to {AUTH_ERROR_CODES}
|
|
296
574
|
def auth_error?(code)
|
|
297
575
|
code && AUTH_ERROR_CODES.include?(code)
|
|
@@ -347,6 +625,9 @@ module Verikloak
|
|
|
347
625
|
class Middleware
|
|
348
626
|
include MiddlewareErrorMapping
|
|
349
627
|
include SkipPathMatcher
|
|
628
|
+
include MiddlewareConfiguration
|
|
629
|
+
include MiddlewareAudienceResolution
|
|
630
|
+
include MiddlewareDecoderCache
|
|
350
631
|
include MiddlewareTokenVerification
|
|
351
632
|
|
|
352
633
|
DEFAULT_REALM = 'verikloak'
|
|
@@ -357,6 +638,7 @@ module Verikloak
|
|
|
357
638
|
# @param discovery_url [String] OIDC discovery endpoint URL
|
|
358
639
|
# @param audience [String, #call] Expected `aud` claim. When a callable is provided it
|
|
359
640
|
# receives the Rack env and may return a String or Array of audiences.
|
|
641
|
+
# @param issuer [String, nil] Optional issuer override (defaults to discovery `issuer`)
|
|
360
642
|
# @param skip_paths [Array<String>] literal paths or wildcard patterns to bypass auth
|
|
361
643
|
# @param discovery [Discovery, nil] custom discovery instance (for DI/tests)
|
|
362
644
|
# @param jwks_cache [JwksCache, nil] custom JWKs cache instance (for DI/tests)
|
|
@@ -367,15 +649,19 @@ module Verikloak
|
|
|
367
649
|
# to TokenDecoder.
|
|
368
650
|
# e.g., { verify_iat: false, leeway: 10 }
|
|
369
651
|
# rubocop:disable Metrics/ParameterLists
|
|
652
|
+
DEFAULT_DECODER_CACHE_LIMIT = 128
|
|
653
|
+
|
|
370
654
|
def initialize(app,
|
|
371
655
|
discovery_url:,
|
|
372
656
|
audience:,
|
|
657
|
+
issuer: nil,
|
|
373
658
|
skip_paths: [],
|
|
374
659
|
discovery: nil,
|
|
375
660
|
jwks_cache: nil,
|
|
376
661
|
connection: nil,
|
|
377
662
|
leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
|
|
378
663
|
token_verify_options: {},
|
|
664
|
+
decoder_cache_limit: DEFAULT_DECODER_CACHE_LIMIT,
|
|
379
665
|
token_env_key: DEFAULT_TOKEN_ENV_KEY,
|
|
380
666
|
user_env_key: DEFAULT_USER_ENV_KEY,
|
|
381
667
|
realm: DEFAULT_REALM,
|
|
@@ -387,9 +673,15 @@ module Verikloak
|
|
|
387
673
|
@jwks_cache = jwks_cache
|
|
388
674
|
@leeway = leeway
|
|
389
675
|
@token_verify_options = token_verify_options || {}
|
|
390
|
-
@
|
|
676
|
+
@decoder_cache_limit = normalize_decoder_cache_limit(decoder_cache_limit)
|
|
677
|
+
# Optional user-configured issuer (overrides discovery issuer when provided)
|
|
678
|
+
@configured_issuer = issuer
|
|
679
|
+
# Effective issuer; may be nil initially and set via discovery if not configured
|
|
680
|
+
@issuer = @configured_issuer
|
|
391
681
|
@mutex = Mutex.new
|
|
392
682
|
@decoder_cache = {}
|
|
683
|
+
@decoder_cache_order = []
|
|
684
|
+
@last_cached_keys_id = nil
|
|
393
685
|
@token_env_key = normalize_env_key(token_env_key, 'token_env_key')
|
|
394
686
|
@user_env_key = normalize_env_key(user_env_key, 'user_env_key')
|
|
395
687
|
@realm = normalize_realm(realm)
|
|
@@ -421,14 +713,16 @@ module Verikloak
|
|
|
421
713
|
|
|
422
714
|
# Returns the Faraday connection used for HTTP operations (Discovery/JWKs).
|
|
423
715
|
# Exposed for tests; not part of public API.
|
|
716
|
+
#
|
|
717
|
+
# @return [Faraday::Connection] The HTTP connection instance
|
|
424
718
|
def http_connection
|
|
425
719
|
@connection
|
|
426
720
|
end
|
|
427
721
|
|
|
428
722
|
# Verifies the token, stores result in Rack env, and forwards to the downstream app.
|
|
429
723
|
#
|
|
430
|
-
# @param env [Hash]
|
|
431
|
-
# @param token [String]
|
|
724
|
+
# @param env [Hash] The Rack environment hash
|
|
725
|
+
# @param token [String] The extracted JWT token
|
|
432
726
|
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
433
727
|
def handle_request(env, token)
|
|
434
728
|
claims = decode_token(env, token)
|
|
@@ -439,8 +733,8 @@ module Verikloak
|
|
|
439
733
|
|
|
440
734
|
# Extracts the Bearer token from the `Authorization` header.
|
|
441
735
|
#
|
|
442
|
-
# @param env [Hash]
|
|
443
|
-
# @return [String]
|
|
736
|
+
# @param env [Hash] The Rack environment hash
|
|
737
|
+
# @return [String] The raw JWT string
|
|
444
738
|
# @raise [Verikloak::MiddlewareError] when the header is missing or malformed
|
|
445
739
|
def extract_token(env)
|
|
446
740
|
auth = env['HTTP_AUTHORIZATION']
|
|
@@ -459,8 +753,8 @@ module Verikloak
|
|
|
459
753
|
|
|
460
754
|
# Converts a raised error into a `[code, http_status]` tuple for response rendering.
|
|
461
755
|
#
|
|
462
|
-
# @param error [Exception]
|
|
463
|
-
# @return [Array(String, Integer)]
|
|
756
|
+
# @param error [Exception] The exception to map
|
|
757
|
+
# @return [Array(String, Integer)] A tuple of error code and HTTP status
|
|
464
758
|
def map_error(error)
|
|
465
759
|
code = error.respond_to?(:code) ? error.code : nil
|
|
466
760
|
|
|
@@ -480,9 +774,9 @@ module Verikloak
|
|
|
480
774
|
|
|
481
775
|
# Builds a JSON error response with RFC 6750 `WWW-Authenticate` header for 401.
|
|
482
776
|
#
|
|
483
|
-
# @param code [String]
|
|
484
|
-
# @param message [String]
|
|
485
|
-
# @param status [Integer]
|
|
777
|
+
# @param code [String] The error code to include in the response
|
|
778
|
+
# @param message [String] The error message to include in the response
|
|
779
|
+
# @param status [Integer] The HTTP status code for the response
|
|
486
780
|
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
487
781
|
def error_response(code = 'unauthorized', message = 'Unauthorized', status = 401)
|
|
488
782
|
body = { error: code, message: message }.to_json
|
|
@@ -509,52 +803,5 @@ module Verikloak
|
|
|
509
803
|
warn backtrace if backtrace
|
|
510
804
|
end
|
|
511
805
|
end
|
|
512
|
-
|
|
513
|
-
def logger_available?
|
|
514
|
-
return false unless @logger
|
|
515
|
-
|
|
516
|
-
@logger.respond_to?(:error) || @logger.respond_to?(:warn) || @logger.respond_to?(:debug)
|
|
517
|
-
end
|
|
518
|
-
|
|
519
|
-
def log_with_logger(message, backtrace)
|
|
520
|
-
log_message(@logger, message)
|
|
521
|
-
log_backtrace(@logger, backtrace)
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
def log_message(logger, message)
|
|
525
|
-
if logger.respond_to?(:error)
|
|
526
|
-
logger.error(message)
|
|
527
|
-
elsif logger.respond_to?(:warn)
|
|
528
|
-
logger.warn(message)
|
|
529
|
-
end
|
|
530
|
-
end
|
|
531
|
-
|
|
532
|
-
def log_backtrace(logger, backtrace)
|
|
533
|
-
return unless backtrace
|
|
534
|
-
|
|
535
|
-
if logger.respond_to?(:debug)
|
|
536
|
-
logger.debug(backtrace)
|
|
537
|
-
elsif logger.respond_to?(:error)
|
|
538
|
-
logger.error(backtrace)
|
|
539
|
-
elsif logger.respond_to?(:warn)
|
|
540
|
-
logger.warn(backtrace)
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def normalize_env_key(value, option_name)
|
|
545
|
-
normalized = value.to_s.strip
|
|
546
|
-
raise ArgumentError, "#{option_name} cannot be blank" if normalized.empty?
|
|
547
|
-
|
|
548
|
-
normalized
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
def normalize_realm(value)
|
|
552
|
-
return DEFAULT_REALM if value.nil?
|
|
553
|
-
|
|
554
|
-
normalized = value.to_s.strip
|
|
555
|
-
raise ArgumentError, 'realm cannot be blank' if normalized.empty?
|
|
556
|
-
|
|
557
|
-
normalized
|
|
558
|
-
end
|
|
559
806
|
end
|
|
560
807
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -109,7 +109,7 @@ metadata:
|
|
|
109
109
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
110
110
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
111
111
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
112
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/0.
|
|
112
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/0.3.0
|
|
113
113
|
rubygems_mfa_required: 'true'
|
|
114
114
|
rdoc_options: []
|
|
115
115
|
require_paths:
|