verikloak 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +66 -50
- data/lib/verikloak/discovery.rb +4 -2
- data/lib/verikloak/http.rb +38 -0
- data/lib/verikloak/jwks_cache.rb +5 -1
- data/lib/verikloak/middleware.rb +162 -21
- data/lib/verikloak/version.rb +1 -1
- data/lib/verikloak.rb +1 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d175a574b17bd1ce5e096dbb69769d12a470f49c8444a5246881afd61d06fa52
|
|
4
|
+
data.tar.gz: 3c59a660bb142c194b6e0db2d338952c41c8a88afc479382203b4d767ab7691c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92edd8a2df134f115c3f887683246278c5778291bb86bfff22ebc873b2b92d6ff09d9c85a1f798e2f49caa09adc8c3ef18ffc83309f95799997ebbdb54b58228
|
|
7
|
+
data.tar.gz: 0e223f0055e69448899be53fc22c9ac32c5d43c410aa041daa38761ed13a00aae8d655bcb6eaff521927a5a029937fcd480c7b90dcfccddf8e6b028aaf0bf555
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2025-09-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Middleware options `token_env_key` and `user_env_key` for customizing where the token and decoded claims are stored in the Rack env.
|
|
14
|
+
- Middleware option `realm` to change the `WWW-Authenticate` realm value emitted on 401 responses.
|
|
15
|
+
- Middleware option `logger` so unexpected internal errors can be sent to the host application's logger instead of STDERR.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Update gem version to 0.2.0 to stay aligned with the rest of the Verikloak ecosystem gems.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## [0.1.5] - 2025-09-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Specs for `Verikloak::HTTP.default_connection`, ensuring retry middleware and timeout defaults stay in sync.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Middleware audience callables now handle zero-arity and BasicObject-style implementations without relying on `method(:call)`.
|
|
29
|
+
- README documents the shared `Verikloak::HTTP.default_connection` helper for reuse/customization.
|
|
30
|
+
|
|
31
|
+
### Dependencies
|
|
32
|
+
- Declare `faraday-retry` as a runtime dependency so the default HTTP connection can load the retry middleware.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
10
36
|
## [0.1.4] - 2025-09-20
|
|
11
37
|
|
|
12
38
|
### Chore
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# verikloak
|
|
2
2
|
|
|
3
3
|
[](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/verikloak)
|
|
@@ -9,8 +9,6 @@ A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenI
|
|
|
9
9
|
|
|
10
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
|
-
---
|
|
13
|
-
|
|
14
12
|
## Features
|
|
15
13
|
|
|
16
14
|
- OpenID Connect Discovery (`.well-known/openid-configuration`)
|
|
@@ -20,8 +18,6 @@ Verikloak is a plug-and-play solution for Ruby (especially Rails API) apps that
|
|
|
20
18
|
- Rails/Rack middleware support
|
|
21
19
|
- Faraday-based customizable HTTP layer
|
|
22
20
|
|
|
23
|
-
---
|
|
24
|
-
|
|
25
21
|
## Installation
|
|
26
22
|
|
|
27
23
|
Add this line to your application's `Gemfile`:
|
|
@@ -36,8 +32,6 @@ Then install:
|
|
|
36
32
|
bundle install
|
|
37
33
|
```
|
|
38
34
|
|
|
39
|
-
---
|
|
40
|
-
|
|
41
35
|
## Usage
|
|
42
36
|
|
|
43
37
|
### Rails (API mode)
|
|
@@ -81,7 +75,6 @@ structured error responses.
|
|
|
81
75
|
> **Note:** When the Rack middleware is enabled, it already renders JSON error responses.
|
|
82
76
|
> The `rescue_from` example above is only necessary if you bypass the middleware or want custom behavior.
|
|
83
77
|
|
|
84
|
-
---
|
|
85
78
|
#### Error Hierarchy
|
|
86
79
|
|
|
87
80
|
All Verikloak errors inherit from `Verikloak::Error`:
|
|
@@ -90,7 +83,7 @@ All Verikloak errors inherit from `Verikloak::Error`:
|
|
|
90
83
|
- `Verikloak::DiscoveryError` – OIDC discovery fetch/parse (`503 Service Unavailable`)
|
|
91
84
|
- `Verikloak::JwksCacheError` – JWKs fetch/parse/cache (`503 Service Unavailable`)
|
|
92
85
|
- `Verikloak::MiddlewareError` – header/infra issues surfaced by the middleware (usually `401`, sometimes `503`)
|
|
93
|
-
|
|
86
|
+
|
|
94
87
|
#### Recommended: use environment variables in production
|
|
95
88
|
|
|
96
89
|
```ruby
|
|
@@ -100,7 +93,27 @@ config.middleware.use Verikloak::Middleware,
|
|
|
100
93
|
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
101
94
|
```
|
|
102
95
|
This makes the configuration secure and flexible across environments.
|
|
103
|
-
|
|
96
|
+
|
|
97
|
+
#### Advanced middleware options
|
|
98
|
+
|
|
99
|
+
`Verikloak::Middleware` exposes a few optional knobs that help integrate with
|
|
100
|
+
different Rack stacks:
|
|
101
|
+
|
|
102
|
+
- `token_env_key` (default: `"verikloak.token"`) — where the raw JWT is stored in the Rack env
|
|
103
|
+
- `user_env_key` (default: `"verikloak.user"`) — where decoded claims are stored
|
|
104
|
+
- `realm` (default: `"verikloak"`) — value used in the `WWW-Authenticate` header for 401 responses
|
|
105
|
+
- `logger` — an object responding to `error` (and optionally `debug`) that receives unexpected 500-level failures
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
config.middleware.use Verikloak::Middleware,
|
|
109
|
+
discovery_url: ENV.fetch("DISCOVERY_URL"),
|
|
110
|
+
audience: ENV.fetch("CLIENT_ID"),
|
|
111
|
+
token_env_key: "rack.session.token",
|
|
112
|
+
user_env_key: "rack.session.claims",
|
|
113
|
+
realm: "my-api",
|
|
114
|
+
logger: Rails.logger
|
|
115
|
+
```
|
|
116
|
+
|
|
104
117
|
### Accessing claims in controllers
|
|
105
118
|
|
|
106
119
|
Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
|
|
@@ -117,7 +130,7 @@ class Api::V1::NotesController < ApplicationController
|
|
|
117
130
|
end
|
|
118
131
|
end
|
|
119
132
|
```
|
|
120
|
-
|
|
133
|
+
|
|
121
134
|
### Standalone Rack app
|
|
122
135
|
|
|
123
136
|
```ruby
|
|
@@ -133,7 +146,6 @@ run ->(env) {
|
|
|
133
146
|
[200, { "Content-Type" => "application/json" }, [user.to_json]]
|
|
134
147
|
}
|
|
135
148
|
```
|
|
136
|
-
---
|
|
137
149
|
|
|
138
150
|
## How It Works
|
|
139
151
|
|
|
@@ -149,8 +161,6 @@ run ->(env) {
|
|
|
149
161
|
- `nbf` (not before)
|
|
150
162
|
7. Makes the decoded payload available in `env["verikloak.user"]`
|
|
151
163
|
|
|
152
|
-
---
|
|
153
|
-
|
|
154
164
|
## Error Responses
|
|
155
165
|
|
|
156
166
|
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.
|
|
@@ -236,13 +246,13 @@ For a full list of error cases and detailed explanations, please see the [ERRORS
|
|
|
236
246
|
| Key | Required | Description |
|
|
237
247
|
| --------------- | -------- | ------------------------------------------- |
|
|
238
248
|
| `discovery_url` | Yes | Full URL to your realm's OIDC discovery doc |
|
|
239
|
-
| `audience` | Yes | Your client ID (checked against `aud`)
|
|
249
|
+
| `audience` | Yes | Your client ID (checked against `aud`). Accepts a String or callable returning a String/Array per request. |
|
|
240
250
|
| `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
|
|
241
251
|
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
242
252
|
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
243
253
|
| `leeway` | No | Clock skew tolerance (seconds) applied during JWT verification. Defaults to `TokenDecoder::DEFAULT_LEEWAY`. |
|
|
244
254
|
| `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.
|
|
255
|
+
| `connection` | No | Inject a Faraday::Connection used for both Discovery and JWKs fetches. Defaults to a safe connection with timeouts and retries. |
|
|
246
256
|
|
|
247
257
|
#### Option: `skip_paths`
|
|
248
258
|
|
|
@@ -264,33 +274,51 @@ Paths **not matched** by any `skip_paths` entry will require a valid JWT.
|
|
|
264
274
|
**Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
|
|
265
275
|
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.
|
|
266
276
|
|
|
277
|
+
#### Option: `audience`
|
|
278
|
+
|
|
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:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
Verikloak::Middleware.new(app,
|
|
283
|
+
discovery_url: ENV['DISCOVERY_URL'],
|
|
284
|
+
audience: ->(env) {
|
|
285
|
+
env['PATH_INFO'].start_with?('/admin') ? 'admin-client-id' : 'public-client-id'
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The callable may also return an Array of audiences when a route is valid for multiple clients.
|
|
291
|
+
|
|
267
292
|
#### Customizing Faraday for Discovery and JWKs
|
|
268
293
|
|
|
269
|
-
Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
|
|
270
|
-
|
|
294
|
+
Both `Discovery` and `JwksCache` accept a `Faraday::Connection`.
|
|
295
|
+
Verikloak ships with a helper you can re-use anywhere:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
connection = Verikloak::HTTP.default_connection
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The default connection enables retries (via `faraday-retry`) for idempotent GET requests and applies conservative timeouts (5s request / 2s open). If you need to add extra middleware, adapters, or instrumentation, you can build on top of the defaults:
|
|
271
302
|
|
|
272
303
|
```ruby
|
|
273
|
-
connection = Faraday.new(request: { timeout:
|
|
304
|
+
connection = Faraday.new(request: { timeout: 10 }) do |f|
|
|
305
|
+
f.request :retry, Verikloak::HTTP::RETRY_OPTIONS
|
|
274
306
|
f.response :logger
|
|
307
|
+
f.adapter Faraday.default_adapter
|
|
275
308
|
end
|
|
276
309
|
|
|
277
310
|
config.middleware.use Verikloak::Middleware,
|
|
278
311
|
discovery_url: ENV["DISCOVERY_URL"],
|
|
279
312
|
audience: ENV["CLIENT_ID"],
|
|
280
|
-
|
|
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.
|
|
313
|
+
connection: connection
|
|
286
314
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
discovery_url: ENV["DISCOVERY_URL"],
|
|
291
|
-
audience: ENV["CLIENT_ID"],
|
|
315
|
+
# Or pass the connection through to a shared JwksCache instance
|
|
316
|
+
jwks_cache = Verikloak::JwksCache.new(
|
|
317
|
+
jwks_uri: "https://example.com/realms/myrealm/protocol/openid-connect/certs",
|
|
292
318
|
connection: connection
|
|
319
|
+
)
|
|
293
320
|
```
|
|
321
|
+
This makes it easy to keep HTTP settings consistent across discovery, JWK refreshes, and any other Verikloak components you wire together.
|
|
294
322
|
|
|
295
323
|
#### Customizing token verification (leeway and options)
|
|
296
324
|
|
|
@@ -314,8 +342,6 @@ config.middleware.use Verikloak::Middleware,
|
|
|
314
342
|
- `token_verify_options:` is passed directly to TokenDecoder (and ultimately to `JWT.decode`).
|
|
315
343
|
- If both are set, `token_verify_options[:leeway]` takes precedence.
|
|
316
344
|
|
|
317
|
-
---
|
|
318
|
-
|
|
319
345
|
#### Performance note
|
|
320
346
|
|
|
321
347
|
Internally, Verikloak caches `TokenDecoder` instances per JWKs fetch to avoid reinitializing
|
|
@@ -330,14 +356,13 @@ Verikloak consists of modular components, each with a focused responsibility:
|
|
|
330
356
|
|----------------|--------------------------------------------------------|--------------|
|
|
331
357
|
| `Middleware` | Rack-compatible entry point for token validation | Rack layer |
|
|
332
358
|
| `Discovery` | Fetches OIDC discovery metadata (`.well-known`) | Network layer|
|
|
359
|
+
| `HTTP` | Provides shared Faraday connection with retries/timeouts | Network layer|
|
|
333
360
|
| `JwksCache` | Fetches & caches JWKs public keys (with ETag) | Cache layer |
|
|
334
361
|
| `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
|
|
335
362
|
| `Errors` | Centralized error hierarchy | Core layer |
|
|
336
363
|
|
|
337
364
|
This separation enables better testing, modular reuse, and flexibility.
|
|
338
365
|
|
|
339
|
-
---
|
|
340
|
-
|
|
341
366
|
## Development (for contributors)
|
|
342
367
|
|
|
343
368
|
Clone and install dependencies:
|
|
@@ -349,8 +374,6 @@ bundle install
|
|
|
349
374
|
```
|
|
350
375
|
See **Testing** below to run specs and RuboCop. For releasing, see **Publishing**.
|
|
351
376
|
|
|
352
|
-
---
|
|
353
|
-
|
|
354
377
|
## Testing
|
|
355
378
|
|
|
356
379
|
All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
|
|
@@ -362,40 +385,33 @@ To run the test suite locally:
|
|
|
362
385
|
docker compose run --rm dev rspec
|
|
363
386
|
docker compose run --rm dev rubocop
|
|
364
387
|
```
|
|
365
|
-
---
|
|
366
388
|
|
|
367
389
|
## Contributing
|
|
368
390
|
|
|
369
391
|
Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
370
392
|
|
|
371
|
-
---
|
|
372
|
-
|
|
373
393
|
## Security
|
|
374
394
|
|
|
375
395
|
If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
|
|
376
396
|
|
|
377
|
-
---
|
|
378
|
-
|
|
379
397
|
## License
|
|
380
398
|
|
|
381
399
|
This project is licensed under the [MIT License](LICENSE).
|
|
382
400
|
|
|
383
|
-
---
|
|
384
|
-
|
|
385
401
|
## Publishing (for maintainers)
|
|
386
402
|
|
|
387
403
|
Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
|
|
388
404
|
|
|
389
|
-
---
|
|
390
|
-
|
|
391
405
|
## Changelog
|
|
392
406
|
|
|
393
407
|
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
394
408
|
|
|
395
|
-
---
|
|
396
|
-
|
|
397
409
|
## References
|
|
398
410
|
|
|
399
|
-
- [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
400
|
-
- [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
|
|
401
|
-
- [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
|
|
411
|
+
- [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
412
|
+
- [Keycloak Documentation: Securing Apps](https://www.keycloak.org/docs/latest/securing_apps/#openid-connect)
|
|
413
|
+
- [JWT RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519)
|
|
414
|
+
- [verikloak-rails on RubyGems](https://rubygems.org/gems/verikloak-rails)
|
|
415
|
+
- [verikloak-bff on RubyGems](https://rubygems.org/gems/verikloak-bff)
|
|
416
|
+
- [verikloak-pundit on RubyGems](https://rubygems.org/gems/verikloak-pundit)
|
|
417
|
+
- [verikloak-audience on RubyGems](https://rubygems.org/gems/verikloak-audience)
|
data/lib/verikloak/discovery.rb
CHANGED
|
@@ -4,6 +4,8 @@ require 'faraday'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'uri'
|
|
6
6
|
|
|
7
|
+
require 'verikloak/http'
|
|
8
|
+
|
|
7
9
|
module Verikloak
|
|
8
10
|
# Fetches and caches the OpenID Connect Discovery document.
|
|
9
11
|
#
|
|
@@ -39,10 +41,10 @@ module Verikloak
|
|
|
39
41
|
REQUIRED_FIELDS = %w[jwks_uri issuer].freeze
|
|
40
42
|
|
|
41
43
|
# @param discovery_url [String] The full URL to the `.well-known/openid-configuration`.
|
|
42
|
-
# @param connection [Faraday::Connection] Optional Faraday client (for DI/tests).
|
|
44
|
+
# @param connection [Faraday::Connection] Optional Faraday client (for DI/tests).
|
|
43
45
|
# @param cache_ttl [Integer] Cache TTL in seconds (default: 3600).
|
|
44
46
|
# @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
|
|
45
|
-
def initialize(discovery_url:, connection:
|
|
47
|
+
def initialize(discovery_url:, connection: Verikloak::HTTP.default_connection, cache_ttl: 3600)
|
|
46
48
|
unless discovery_url.is_a?(String) && discovery_url.strip.match?(%r{^https?://})
|
|
47
49
|
raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
|
|
48
50
|
code: 'invalid_discovery_url')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
5
|
+
|
|
6
|
+
module Verikloak
|
|
7
|
+
# Internal HTTP helpers shared across components.
|
|
8
|
+
module HTTP
|
|
9
|
+
# Default request timeout (seconds) for outbound discovery/JWKs calls.
|
|
10
|
+
DEFAULT_TIMEOUT = 5
|
|
11
|
+
# Default open/read timeout (seconds) before establishing the HTTP connection.
|
|
12
|
+
DEFAULT_OPEN_TIMEOUT = 2
|
|
13
|
+
|
|
14
|
+
# Retry middleware configuration used for idempotent GET requests.
|
|
15
|
+
# Retries on 429/5xx with exponential backoff and jitter.
|
|
16
|
+
RETRY_OPTIONS = {
|
|
17
|
+
max: 2,
|
|
18
|
+
interval: 0.1,
|
|
19
|
+
interval_randomness: 0.2,
|
|
20
|
+
backoff_factor: 2,
|
|
21
|
+
methods: %i[get],
|
|
22
|
+
retry_statuses: [429, 500, 502, 503, 504]
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Builds a Faraday connection with conservative defaults suitable for
|
|
26
|
+
# network-bound operations (discovery and JWKs fetching).
|
|
27
|
+
#
|
|
28
|
+
# @return [Faraday::Connection]
|
|
29
|
+
def self.default_connection
|
|
30
|
+
Faraday.new do |f|
|
|
31
|
+
f.request :retry, RETRY_OPTIONS
|
|
32
|
+
f.options.timeout = DEFAULT_TIMEOUT
|
|
33
|
+
f.options.open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
34
|
+
f.adapter Faraday.default_adapter
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/verikloak/jwks_cache.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
|
+
require 'verikloak/http'
|
|
7
|
+
|
|
6
8
|
module Verikloak
|
|
7
9
|
# Caches and revalidates JSON Web Key Sets (JWKs) fetched from a remote endpoint.
|
|
8
10
|
#
|
|
@@ -43,7 +45,7 @@ module Verikloak
|
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
@jwks_uri = jwks_uri
|
|
46
|
-
@connection = connection ||
|
|
48
|
+
@connection = connection || Verikloak::HTTP.default_connection
|
|
47
49
|
@cached_keys = nil
|
|
48
50
|
@etag = nil
|
|
49
51
|
@fetched_at = nil
|
|
@@ -59,6 +61,8 @@ module Verikloak
|
|
|
59
61
|
# @return [Array<Hash>] the cached JWKs after fetch/revalidation
|
|
60
62
|
# @raise [JwksCacheError] on HTTP failures, invalid JSON, invalid structure, or cache miss on 304
|
|
61
63
|
def fetch!
|
|
64
|
+
return @cached_keys if fresh_by_ttl?
|
|
65
|
+
|
|
62
66
|
with_error_handling do
|
|
63
67
|
# Build conditional request headers (ETag-based)
|
|
64
68
|
headers = build_conditional_headers
|
data/lib/verikloak/middleware.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'json'
|
|
|
5
5
|
require 'set'
|
|
6
6
|
require 'faraday'
|
|
7
7
|
|
|
8
|
+
require 'verikloak/http'
|
|
9
|
+
|
|
8
10
|
module Verikloak
|
|
9
11
|
# @api private
|
|
10
12
|
#
|
|
@@ -106,12 +108,12 @@ module Verikloak
|
|
|
106
108
|
|
|
107
109
|
# Returns a cached TokenDecoder instance for current inputs.
|
|
108
110
|
# Cache key uses issuer, audience, leeway, token_verify_options, and JWKs fetched_at timestamp.
|
|
109
|
-
def decoder_for
|
|
111
|
+
def decoder_for(audience)
|
|
110
112
|
keys = @jwks_cache.cached
|
|
111
113
|
fetched_at = @jwks_cache.respond_to?(:fetched_at) ? @jwks_cache.fetched_at : nil
|
|
112
114
|
cache_key = [
|
|
113
115
|
@issuer,
|
|
114
|
-
|
|
116
|
+
audience,
|
|
115
117
|
@leeway,
|
|
116
118
|
@token_verify_options,
|
|
117
119
|
fetched_at
|
|
@@ -120,7 +122,7 @@ module Verikloak
|
|
|
120
122
|
@decoder_cache[cache_key] ||= TokenDecoder.new(
|
|
121
123
|
jwks: keys,
|
|
122
124
|
issuer: @issuer,
|
|
123
|
-
audience:
|
|
125
|
+
audience: audience,
|
|
124
126
|
leeway: @leeway,
|
|
125
127
|
options: @token_verify_options
|
|
126
128
|
)
|
|
@@ -142,14 +144,16 @@ module Verikloak
|
|
|
142
144
|
# @param token [String]
|
|
143
145
|
# @return [Hash] decoded JWT claims
|
|
144
146
|
# @raise [Verikloak::Error] bubbles up verification/fetch errors for centralized handling
|
|
145
|
-
def decode_token(token)
|
|
147
|
+
def decode_token(env, token)
|
|
146
148
|
ensure_jwks_cache!
|
|
147
149
|
if @jwks_cache.cached.nil? || @jwks_cache.cached.empty?
|
|
148
150
|
raise MiddlewareError.new('JWKs cache is empty, cannot verify token', code: 'jwks_cache_miss')
|
|
149
151
|
end
|
|
150
152
|
|
|
153
|
+
audience = resolve_audience(env)
|
|
154
|
+
|
|
151
155
|
# First attempt
|
|
152
|
-
decoder = decoder_for
|
|
156
|
+
decoder = decoder_for(audience)
|
|
153
157
|
|
|
154
158
|
begin
|
|
155
159
|
decoder.decode!(token)
|
|
@@ -160,11 +164,78 @@ module Verikloak
|
|
|
160
164
|
refresh_jwks!
|
|
161
165
|
|
|
162
166
|
# Rebuild decoder with refreshed keys and try once more.
|
|
163
|
-
decoder = decoder_for
|
|
167
|
+
decoder = decoder_for(audience)
|
|
164
168
|
decoder.decode!(token)
|
|
165
169
|
end
|
|
166
170
|
end
|
|
167
171
|
|
|
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
|
+
|
|
168
239
|
# Ensures that discovery metadata and JWKs cache are initialized and up-to-date.
|
|
169
240
|
# This method is thread-safe.
|
|
170
241
|
#
|
|
@@ -278,17 +349,24 @@ module Verikloak
|
|
|
278
349
|
include SkipPathMatcher
|
|
279
350
|
include MiddlewareTokenVerification
|
|
280
351
|
|
|
352
|
+
DEFAULT_REALM = 'verikloak'
|
|
353
|
+
DEFAULT_TOKEN_ENV_KEY = 'verikloak.token'
|
|
354
|
+
DEFAULT_USER_ENV_KEY = 'verikloak.user'
|
|
355
|
+
|
|
281
356
|
# @param app [#call] downstream Rack app
|
|
282
357
|
# @param discovery_url [String] OIDC discovery endpoint URL
|
|
283
|
-
# @param audience [String]
|
|
358
|
+
# @param audience [String, #call] Expected `aud` claim. When a callable is provided it
|
|
359
|
+
# receives the Rack env and may return a String or Array of audiences.
|
|
284
360
|
# @param skip_paths [Array<String>] literal paths or wildcard patterns to bypass auth
|
|
285
361
|
# @param discovery [Discovery, nil] custom discovery instance (for DI/tests)
|
|
286
362
|
# @param jwks_cache [JwksCache, nil] custom JWKs cache instance (for DI/tests)
|
|
287
|
-
# @param connection [Faraday::Connection, nil] Optional injected Faraday connection
|
|
363
|
+
# @param connection [Faraday::Connection, nil] Optional injected Faraday connection
|
|
364
|
+
# (defaults to {Verikloak::HTTP.default_connection})
|
|
288
365
|
# @param leeway [Integer] Clock skew tolerance in seconds for token verification (delegated to TokenDecoder)
|
|
289
366
|
# @param token_verify_options [Hash] Additional JWT verification options passed through
|
|
290
367
|
# to TokenDecoder.
|
|
291
368
|
# e.g., { verify_iat: false, leeway: 10 }
|
|
369
|
+
# rubocop:disable Metrics/ParameterLists
|
|
292
370
|
def initialize(app,
|
|
293
371
|
discovery_url:,
|
|
294
372
|
audience:,
|
|
@@ -297,20 +375,29 @@ module Verikloak
|
|
|
297
375
|
jwks_cache: nil,
|
|
298
376
|
connection: nil,
|
|
299
377
|
leeway: Verikloak::TokenDecoder::DEFAULT_LEEWAY,
|
|
300
|
-
token_verify_options: {}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
@
|
|
306
|
-
@
|
|
378
|
+
token_verify_options: {},
|
|
379
|
+
token_env_key: DEFAULT_TOKEN_ENV_KEY,
|
|
380
|
+
user_env_key: DEFAULT_USER_ENV_KEY,
|
|
381
|
+
realm: DEFAULT_REALM,
|
|
382
|
+
logger: nil)
|
|
383
|
+
@app = app
|
|
384
|
+
@connection = connection || Verikloak::HTTP.default_connection
|
|
385
|
+
@audience_source = audience
|
|
386
|
+
@discovery = discovery || Discovery.new(discovery_url: discovery_url, connection: @connection)
|
|
387
|
+
@jwks_cache = jwks_cache
|
|
388
|
+
@leeway = leeway
|
|
307
389
|
@token_verify_options = token_verify_options || {}
|
|
308
390
|
@issuer = nil
|
|
309
391
|
@mutex = Mutex.new
|
|
310
392
|
@decoder_cache = {}
|
|
393
|
+
@token_env_key = normalize_env_key(token_env_key, 'token_env_key')
|
|
394
|
+
@user_env_key = normalize_env_key(user_env_key, 'user_env_key')
|
|
395
|
+
@realm = normalize_realm(realm)
|
|
396
|
+
@logger = logger
|
|
311
397
|
|
|
312
398
|
compile_skip_paths(skip_paths)
|
|
313
399
|
end
|
|
400
|
+
# rubocop:enable Metrics/ParameterLists
|
|
314
401
|
|
|
315
402
|
# Rack entrypoint.
|
|
316
403
|
#
|
|
@@ -344,9 +431,9 @@ module Verikloak
|
|
|
344
431
|
# @param token [String]
|
|
345
432
|
# @return [Array(Integer, Hash, Array<String>)] Rack response triple
|
|
346
433
|
def handle_request(env, token)
|
|
347
|
-
claims = decode_token(token)
|
|
348
|
-
env[
|
|
349
|
-
env[
|
|
434
|
+
claims = decode_token(env, token)
|
|
435
|
+
env[@token_env_key] = token
|
|
436
|
+
env[@user_env_key] = claims
|
|
350
437
|
@app.call(env)
|
|
351
438
|
end
|
|
352
439
|
|
|
@@ -402,7 +489,7 @@ module Verikloak
|
|
|
402
489
|
headers = { 'Content-Type' => 'application/json' }
|
|
403
490
|
if status == 401
|
|
404
491
|
headers['WWW-Authenticate'] =
|
|
405
|
-
%(Bearer realm="
|
|
492
|
+
%(Bearer realm="#{@realm}", error="#{code}", error_description="#{message.gsub('"', '\\"')}")
|
|
406
493
|
end
|
|
407
494
|
[status, headers, [body]]
|
|
408
495
|
end
|
|
@@ -412,8 +499,62 @@ module Verikloak
|
|
|
412
499
|
# @param error [Exception]
|
|
413
500
|
# @return [void]
|
|
414
501
|
def log_internal_error(error)
|
|
415
|
-
|
|
416
|
-
|
|
502
|
+
message = "[verikloak] Internal error: #{error.class} - #{error.message}"
|
|
503
|
+
backtrace = error.backtrace&.join("\n")
|
|
504
|
+
|
|
505
|
+
if logger_available?
|
|
506
|
+
log_with_logger(message, backtrace)
|
|
507
|
+
else
|
|
508
|
+
warn message
|
|
509
|
+
warn backtrace if backtrace
|
|
510
|
+
end
|
|
511
|
+
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
|
|
417
558
|
end
|
|
418
559
|
end
|
|
419
560
|
end
|
data/lib/verikloak/version.rb
CHANGED
data/lib/verikloak.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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- taiyaky
|
|
@@ -29,6 +29,26 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '3.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: faraday-retry
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '2.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '3.0'
|
|
32
52
|
- !ruby/object:Gem::Dependency
|
|
33
53
|
name: json
|
|
34
54
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -77,6 +97,7 @@ files:
|
|
|
77
97
|
- lib/verikloak.rb
|
|
78
98
|
- lib/verikloak/discovery.rb
|
|
79
99
|
- lib/verikloak/errors.rb
|
|
100
|
+
- lib/verikloak/http.rb
|
|
80
101
|
- lib/verikloak/jwks_cache.rb
|
|
81
102
|
- lib/verikloak/middleware.rb
|
|
82
103
|
- lib/verikloak/token_decoder.rb
|
|
@@ -88,7 +109,7 @@ metadata:
|
|
|
88
109
|
source_code_uri: https://github.com/taiyaky/verikloak
|
|
89
110
|
changelog_uri: https://github.com/taiyaky/verikloak/blob/main/CHANGELOG.md
|
|
90
111
|
bug_tracker_uri: https://github.com/taiyaky/verikloak/issues
|
|
91
|
-
documentation_uri: https://rubydoc.info/gems/verikloak/0.
|
|
112
|
+
documentation_uri: https://rubydoc.info/gems/verikloak/0.2.0
|
|
92
113
|
rubygems_mfa_required: 'true'
|
|
93
114
|
rdoc_options: []
|
|
94
115
|
require_paths:
|