verikloak 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +324 -0
- data/lib/verikloak/discovery.rb +246 -0
- data/lib/verikloak/errors.rb +66 -0
- data/lib/verikloak/jwks_cache.rb +242 -0
- data/lib/verikloak/middleware.rb +322 -0
- data/lib/verikloak/token_decoder.rb +246 -0
- data/lib/verikloak/version.rb +6 -0
- data/lib/verikloak.rb +11 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d16d7e5f8602a8484837e30752c190825e5f83b3ad9cb6b90d5e827b910b0daa
|
|
4
|
+
data.tar.gz: 135a5eadb529463081cfcf414d48ce13328fecf13b95963eff010a211f541c8f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5d476daca7174cb474771005affced764ad2093d633809c4206d54e5d0467b45e903bf998cbbca5cb9e25802e29cfaae20dd04d2820d53fc59c844af5cd886c7
|
|
7
|
+
data.tar.gz: f66dba005f8ad96afb0de678ab65e76a99d4f08dbcc98d4bc7468a6c5183781b10f10c4916a24c28ab7e8b459e6f252dbac01fc536f5d0f99e116a38d7c24485
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [0.1.1] - 2025-08-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Updated dependency constraints in gemspec (`json` ~> 2.6, `jwt` ~> 2.7) for better compatibility control
|
|
15
|
+
- Updated README badges (Gem version, Ruby version, downloads)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## [0.1.0] - 2025-08-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Initial release of `verikloak`
|
|
24
|
+
- Rack middleware for verifying JWT access tokens from Keycloak
|
|
25
|
+
- Support for OpenID Connect Discovery (`.well-known/openid-configuration`)
|
|
26
|
+
- Handles up to 3 HTTP redirects and resolves relative `Location` headers
|
|
27
|
+
- JWKS fetching with in-memory caching and ETag validation
|
|
28
|
+
- RS256 JWT verification with `kid` matching
|
|
29
|
+
- Claim validation: `aud`, `iss`, `exp`, `nbf`
|
|
30
|
+
- Configurable via `discovery_url`, `audience`, and `skip_paths` options
|
|
31
|
+
- `skip_paths` supports `/`, literal paths, and `*` wildcards (e.g. `/public/*`, `/rails/*`)
|
|
32
|
+
- Environment keys set by middleware:
|
|
33
|
+
- `env["verikloak.user"]` for decoded claims
|
|
34
|
+
- `env["verikloak.token"]` for the raw Bearer token
|
|
35
|
+
- Comprehensive RSpec test suite:
|
|
36
|
+
- `TokenDecoder` unit tests
|
|
37
|
+
- `Discovery` behavior (redirects, invalid JSON, required fields)
|
|
38
|
+
- `JwksCache` behavior (ETag/304, parse/validation errors)
|
|
39
|
+
- Rack middleware integration tests (401/503 mapping, header parsing)
|
|
40
|
+
- Docker-based development and CI-ready setup
|
|
41
|
+
- RuboCop static analysis configuration
|
|
42
|
+
- Structured error handling & responses:
|
|
43
|
+
- Token/auth errors → **401 Unauthorized** with `WWW-Authenticate` header (RFC 6750)
|
|
44
|
+
- Discovery/JWKS errors → **503 Service Unavailable**
|
|
45
|
+
- Structured error codes: `invalid_token`, `expired_token`, `not_yet_valid`,
|
|
46
|
+
`invalid_issuer`, `invalid_audience`, `unsupported_algorithm`,
|
|
47
|
+
`jwks_fetch_failed`, `jwks_parse_failed`, `jwks_cache_miss`,
|
|
48
|
+
`discovery_metadata_fetch_failed`, `discovery_metadata_invalid`,
|
|
49
|
+
`discovery_redirect_error`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 taiyaky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Verikloak
|
|
2
|
+
|
|
3
|
+
[](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/verikloak)
|
|
5
|
+

|
|
6
|
+
[](https://rubygems.org/gems/verikloak)
|
|
7
|
+
|
|
8
|
+
A lightweight Rack middleware for verifying Keycloak JWT access tokens via OpenID Connect.
|
|
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 JWKS to fetch the public keys and verify JWT signatures securely.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- OpenID Connect Discovery (`.well-known/openid-configuration`)
|
|
17
|
+
- JWKs auto-fetch and in-memory caching with ETag support
|
|
18
|
+
- RS256 JWT verification using `kid`
|
|
19
|
+
- `aud`, `iss`, `exp`, `nbf` claim validation
|
|
20
|
+
- Rails/Rack middleware support
|
|
21
|
+
- Faraday-based customizable HTTP layer
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Add this line to your application's `Gemfile`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
gem "verikloak"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then install:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Rails (API mode)
|
|
44
|
+
|
|
45
|
+
Add the middleware in `config/application.rb`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
config.middleware.use Verikloak::Middleware,
|
|
49
|
+
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
|
|
50
|
+
audience: "your-client-id",
|
|
51
|
+
skip_paths: ['/skip_path']
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
#### Handling Authentication Failures
|
|
55
|
+
|
|
56
|
+
By default, invalid or missing tokens will raise a `Verikloak::Errors::InvalidToken` error.
|
|
57
|
+
In a Rails app, you can rescue this globally and return a consistent JSON response:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class ApplicationController < ActionController::API
|
|
61
|
+
rescue_from Verikloak::Errors::InvalidToken do |e|
|
|
62
|
+
render json: { error: e.message }, status: :unauthorized
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This ensures clients always receive a structured `401 Unauthorized` response.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
#### Recommended: use environment variables in production
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
config.middleware.use Verikloak::Middleware,
|
|
75
|
+
discovery_url: ENV.fetch("DISCOVERY_URL"),
|
|
76
|
+
audience: ENV.fetch("CLIENT_ID"),
|
|
77
|
+
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
78
|
+
```
|
|
79
|
+
#### In production, set these variables in your environment for security and flexibility.
|
|
80
|
+
|
|
81
|
+
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
|
+
---
|
|
88
|
+
### Accessing claims in controllers
|
|
89
|
+
|
|
90
|
+
Once the middleware is enabled, Verikloak adds the decoded token and raw JWT to the Rack environment.
|
|
91
|
+
You can access them in any Rails controller:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class Api::V1::NotesController < ApplicationController
|
|
95
|
+
def index
|
|
96
|
+
user_claims = request.env["verikloak.user"] # Hash of decoded Keycloak JWT claims
|
|
97
|
+
token = request.env["verikloak.token"] # Raw JWT token string
|
|
98
|
+
|
|
99
|
+
# Example: use claims for authorization or logging
|
|
100
|
+
render json: { sub: user_claims["sub"], email: user_claims["email"] }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
---
|
|
105
|
+
### Standalone Rack app
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# config.ru example for a standalone Rack app
|
|
109
|
+
require "verikloak"
|
|
110
|
+
|
|
111
|
+
use Verikloak::Middleware,
|
|
112
|
+
discovery_url: "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration",
|
|
113
|
+
audience: "my-client-id"
|
|
114
|
+
|
|
115
|
+
run ->(env) {
|
|
116
|
+
user = env["verikloak.user"] # Decoded JWT claims hash (if token is valid)
|
|
117
|
+
[200, { "Content-Type" => "application/json" }, [user.to_json]]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## How It Works
|
|
123
|
+
|
|
124
|
+
1. Extracts the `Authorization: Bearer <token>` header
|
|
125
|
+
2. Fetches the OIDC discovery document (only once or when expired)
|
|
126
|
+
3. Downloads JWKS public keys from the provided `jwks_uri`
|
|
127
|
+
4. Matches the `kid` from JWT header to select the right JWK
|
|
128
|
+
5. Decodes and verifies the JWT using `RS256`
|
|
129
|
+
6. Validates the following claims:
|
|
130
|
+
- `aud` (audience)
|
|
131
|
+
- `iss` (issuer)
|
|
132
|
+
- `exp` (expiration)
|
|
133
|
+
- `nbf` (not before)
|
|
134
|
+
7. Makes the decoded payload available in `env["verikloak.user"]`
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Error Responses
|
|
139
|
+
|
|
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/JWKS errors, and 500 for unexpected internal errors.
|
|
141
|
+
|
|
142
|
+
### Common HTTP Responses
|
|
143
|
+
|
|
144
|
+
- `401 Unauthorized`: The access token is missing, invalid, expired, or otherwise not valid.
|
|
145
|
+
- `503 Service Unavailable`: Discovery or JWKS fetch/parsing failed (server-side issue).
|
|
146
|
+
- `500 Internal Server Error`: An unexpected error occurred.
|
|
147
|
+
|
|
148
|
+
### Representative Examples
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"error": "invalid_token",
|
|
153
|
+
"message": "The access token is missing or invalid"
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"error": "expired_token",
|
|
160
|
+
"message": "The access token has expired"
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"error": "jwks_fetch_failed",
|
|
167
|
+
"message": "Failed to fetch JWKS keys"
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"error": "jwks_parse_failed",
|
|
174
|
+
"message": "Failed to parse JWKS keys"
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```json
|
|
179
|
+
{
|
|
180
|
+
"error": "discovery_metadata_fetch_failed",
|
|
181
|
+
"message": "Failed to fetch OIDC discovery document"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"error": "discovery_metadata_invalid",
|
|
188
|
+
"message": "Failed to parse OIDC discovery document"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Error Types
|
|
193
|
+
|
|
194
|
+
| Error Code | HTTP Status | Description |
|
|
195
|
+
|----------------------------|---------------------------|-----------------------------------------------------------------------------------------------|
|
|
196
|
+
| `invalid_token` | 401 Unauthorized | The token is missing, malformed, or invalid |
|
|
197
|
+
| `expired_token` | 401 Unauthorized | The token has expired |
|
|
198
|
+
| `missing_authorization_header` | 401 Unauthorized | The `Authorization` header is missing |
|
|
199
|
+
| `invalid_authorization_header` | 401 Unauthorized | The `Authorization` header format is invalid |
|
|
200
|
+
| `unsupported_algorithm` | 401 Unauthorized | The token’s signing algorithm is not supported |
|
|
201
|
+
| `invalid_signature` | 401 Unauthorized | The token signature could not be verified |
|
|
202
|
+
| `invalid_issuer` | 401 Unauthorized | Invalid `iss` claim |
|
|
203
|
+
| `invalid_audience` | 401 Unauthorized | Invalid `aud` claim |
|
|
204
|
+
| `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 JWKS keys |
|
|
206
|
+
| `jwks_parse_failed` | 503 Service Unavailable | Failed to parse JWKS keys |
|
|
207
|
+
| `jwks_cache_miss` | 503 Service Unavailable | JWKS cache is empty (e.g., 304 Not Modified without prior cache) |
|
|
208
|
+
| `discovery_metadata_fetch_failed` | 503 Service Unavailable | Failed to fetch OIDC discovery document |
|
|
209
|
+
| `discovery_metadata_invalid` | 503 Service Unavailable | Failed to parse OIDC discovery document |
|
|
210
|
+
| `discovery_redirect_error` | 503 Service Unavailable | Discovery response was a redirect without a valid Location header |
|
|
211
|
+
| `internal_server_error` | 500 Internal Server Error | Unexpected internal error (catch-all) |
|
|
212
|
+
|
|
213
|
+
> Note: The `decode_with_public_key` method ensures consistent error codes for all JWT verification failures.
|
|
214
|
+
> It may raise `invalid_signature`, `unsupported_algorithm`, `expired_token`, `invalid_issuer`, `invalid_audience`, or `not_yet_valid` depending on the verification outcome.
|
|
215
|
+
|
|
216
|
+
For a full list of error cases and detailed explanations, please see the [ERRORS.md](ERRORS.md) file.
|
|
217
|
+
|
|
218
|
+
## Configuration Options
|
|
219
|
+
|
|
220
|
+
| Key | Required | Description |
|
|
221
|
+
| --------------- | -------- | ------------------------------------------- |
|
|
222
|
+
| `discovery_url` | Yes | Full URL to your realm's OIDC discovery doc |
|
|
223
|
+
| `audience` | Yes | Your client ID (checked against `aud`) |
|
|
224
|
+
| `skip_paths` | No | Array of paths or wildcards to skip authentication, e.g. `['/', '/health', '/public/*']`. **Note:** Regex patterns are not supported. |
|
|
225
|
+
| `discovery` | No | Inject custom Discovery instance (advanced/testing) |
|
|
226
|
+
| `jwks_cache` | No | Inject custom JwksCache instance (advanced/testing) |
|
|
227
|
+
|
|
228
|
+
#### Option: `skip_paths`
|
|
229
|
+
|
|
230
|
+
`skip_paths` lets you specify paths (or wildcard patterns) where authentication should be **skipped**.
|
|
231
|
+
For example:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
skip_paths: ['/', '/health', '/public/*', '/rails/*']
|
|
235
|
+
```
|
|
236
|
+
- `'/'` matches only the root path.
|
|
237
|
+
- `'/foo/*'` matches `/foo` and any subpath like `/foo/bar` or `/foo/bar/baz`.
|
|
238
|
+
- `'/api/public'` matches **only** `/api/public` (for subpaths, use `'/api/public/*'`).
|
|
239
|
+
- `'/rails/*'` matches `/rails` itself as well as `/rails/foo`, `/rails/foo/bar`, etc.
|
|
240
|
+
|
|
241
|
+
Paths **not matched** by any `skip_paths` entry will require a valid JWT.
|
|
242
|
+
|
|
243
|
+
**Note:** Regex patterns are not supported. Only literal paths and `*` wildcards are allowed.
|
|
244
|
+
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
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Architecture
|
|
249
|
+
|
|
250
|
+
Verikloak consists of modular components, each with a focused responsibility:
|
|
251
|
+
|
|
252
|
+
| Component | Responsibility | Layer |
|
|
253
|
+
|----------------|--------------------------------------------------------|--------------|
|
|
254
|
+
| `Middleware` | Rack-compatible entry point for token validation | Rack layer |
|
|
255
|
+
| `Discovery` | Fetches OIDC discovery metadata (`.well-known`) | Network layer|
|
|
256
|
+
| `JwksCache` | Fetches & caches JWKS public keys (with ETag) | Cache layer |
|
|
257
|
+
| `TokenDecoder` | Decodes and verifies JWTs (signature, exp, nbf, iss, aud) | Crypto layer |
|
|
258
|
+
| `Errors` | Centralized error hierarchy | Core layer |
|
|
259
|
+
|
|
260
|
+
This separation enables better testing, modular reuse, and flexibility.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Development (for contributors)
|
|
265
|
+
|
|
266
|
+
Clone and install dependencies:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
git clone https://github.com/taiyaky/verikloak.git
|
|
270
|
+
cd verikloak
|
|
271
|
+
bundle install
|
|
272
|
+
```
|
|
273
|
+
See **Testing** below to run specs and RuboCop. For releasing, see **Publishing**.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Testing
|
|
278
|
+
|
|
279
|
+
All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
|
|
280
|
+
See the CI badge at the top for current build status.
|
|
281
|
+
|
|
282
|
+
To run the test suite locally:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
docker compose run --rm dev rspec
|
|
286
|
+
docker compose run --rm dev rubocop
|
|
287
|
+
```
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Contributing
|
|
291
|
+
|
|
292
|
+
Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Security
|
|
297
|
+
|
|
298
|
+
If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Publishing (for maintainers)
|
|
309
|
+
|
|
310
|
+
Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Changelog
|
|
315
|
+
|
|
316
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## References
|
|
321
|
+
|
|
322
|
+
- [OpenID Connect Discovery 1.0 Spec](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
323
|
+
- [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)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Verikloak
|
|
8
|
+
# Fetches and caches the OpenID Connect Discovery document.
|
|
9
|
+
#
|
|
10
|
+
# This class retrieves the discovery metadata from an OpenID Connect provider
|
|
11
|
+
# (e.g., Keycloak) using the `.well-known/openid-configuration` endpoint.
|
|
12
|
+
# It validates required fields such as `jwks_uri` and `issuer`, and supports:
|
|
13
|
+
#
|
|
14
|
+
# - Dependency Injection of Faraday connection for testing and middleware
|
|
15
|
+
# - In-memory caching with configurable TTL
|
|
16
|
+
# - Thread safety via Mutex
|
|
17
|
+
# - Automatic handling of common HTTP statuses (including multi-hop redirects)
|
|
18
|
+
#
|
|
19
|
+
# ### Thread-safety
|
|
20
|
+
# `#fetch!` is synchronized, so concurrent callers share the same cached value and refresh.
|
|
21
|
+
#
|
|
22
|
+
# ### Errors
|
|
23
|
+
# Raises {Verikloak::DiscoveryError} with one of the following `code`s:
|
|
24
|
+
# - `invalid_discovery_url`
|
|
25
|
+
# - `discovery_metadata_fetch_failed`
|
|
26
|
+
# - `discovery_metadata_invalid`
|
|
27
|
+
# - `discovery_redirect_error`
|
|
28
|
+
#
|
|
29
|
+
# @example Basic usage
|
|
30
|
+
# discovery = Verikloak::Discovery.new(
|
|
31
|
+
# discovery_url: "https://keycloak.example.com/realms/demo/.well-known/openid-configuration"
|
|
32
|
+
# )
|
|
33
|
+
# json = discovery.fetch!
|
|
34
|
+
# json["issuer"] #=> "https://keycloak.example.com/realms/demo"
|
|
35
|
+
# json["jwks_uri"] #=> "https://keycloak.example.com/realms/demo/protocol/openid-connect/certs"
|
|
36
|
+
class Discovery
|
|
37
|
+
# Required keys that must be present in the discovery document.
|
|
38
|
+
# @return [Array<String>]
|
|
39
|
+
REQUIRED_FIELDS = %w[jwks_uri issuer].freeze
|
|
40
|
+
|
|
41
|
+
# @param discovery_url [String] The full URL to the `.well-known/openid-configuration`.
|
|
42
|
+
# @param connection [Faraday::Connection] Optional Faraday client (for DI/tests). Defaults to `Faraday.new`.
|
|
43
|
+
# @param cache_ttl [Integer] Cache TTL in seconds (default: 3600).
|
|
44
|
+
# @raise [DiscoveryError] when `discovery_url` is not a valid HTTP(S) URL
|
|
45
|
+
def initialize(discovery_url:, connection: Faraday.new, cache_ttl: 3600)
|
|
46
|
+
unless discovery_url.is_a?(String) && discovery_url.strip.match?(%r{^https?://})
|
|
47
|
+
raise DiscoveryError.new('Invalid discovery URL: must be a non-empty HTTP(S) URL',
|
|
48
|
+
code: 'invalid_discovery_url')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@discovery_url = discovery_url
|
|
52
|
+
@conn = connection
|
|
53
|
+
@cache_ttl = cache_ttl
|
|
54
|
+
@cached_json = nil
|
|
55
|
+
@fetched_at = nil
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Fetches and parses the discovery document, using the in-memory cache if fresh.
|
|
60
|
+
#
|
|
61
|
+
# Cache freshness is determined by `cache_ttl` from initialization.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash] Parsed JSON object containing discovery metadata.
|
|
64
|
+
# @raise [DiscoveryError] if the request fails, the response is invalid, or required fields are missing.
|
|
65
|
+
def fetch!
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
# Return cached if within TTL
|
|
68
|
+
return @cached_json if @cached_json && (Time.now - @fetched_at) < @cache_ttl
|
|
69
|
+
|
|
70
|
+
# Fetch fresh document
|
|
71
|
+
json = with_error_handling { fetch_and_parse_json_from_url }
|
|
72
|
+
validate_required_fields!(json)
|
|
73
|
+
|
|
74
|
+
# Update cache
|
|
75
|
+
@cached_json = json
|
|
76
|
+
@fetched_at = Time.now
|
|
77
|
+
json
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Performs the initial HTTP GET and handles redirects, returning the parsed JSON.
|
|
84
|
+
# @api private
|
|
85
|
+
# @return [Hash]
|
|
86
|
+
def fetch_and_parse_json_from_url
|
|
87
|
+
response = @conn.get(@discovery_url)
|
|
88
|
+
response = follow_redirects(response, max_hops: 3, base_url: @discovery_url)
|
|
89
|
+
handle_final_response(response)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Handles terminal (non-redirect) responses and parses JSON on 200 OK.
|
|
93
|
+
# Maps common failure statuses to {DiscoveryError} with appropriate codes.
|
|
94
|
+
# @api private
|
|
95
|
+
# @param response [Faraday::Response]
|
|
96
|
+
# @return [Hash]
|
|
97
|
+
# @raise [DiscoveryError]
|
|
98
|
+
def handle_final_response(response)
|
|
99
|
+
status = response.status
|
|
100
|
+
return parse_json(response.body) if status == 200
|
|
101
|
+
|
|
102
|
+
if status == 404
|
|
103
|
+
# If the 404 occurred after a redirect (final URL differs from the original discovery URL),
|
|
104
|
+
# keep the generic message to align with redirect tests; otherwise use a specific "not found" message.
|
|
105
|
+
final_url = response.respond_to?(:env) && response.env&.url ? response.env.url.to_s : nil
|
|
106
|
+
message = if final_url && final_url != @discovery_url
|
|
107
|
+
'Failed to fetch discovery document: status 404'
|
|
108
|
+
else
|
|
109
|
+
'Discovery document not found (404)'
|
|
110
|
+
end
|
|
111
|
+
raise DiscoveryError.new(message, code: 'discovery_metadata_fetch_failed')
|
|
112
|
+
end
|
|
113
|
+
if (500..599).cover?(status)
|
|
114
|
+
raise DiscoveryError.new("Discovery endpoint server error: status #{status}",
|
|
115
|
+
code: 'discovery_metadata_fetch_failed')
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
raise DiscoveryError.new("Failed to fetch discovery document: status #{status}",
|
|
119
|
+
code: 'discovery_metadata_fetch_failed')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Follows HTTP redirects up to `max_hops`, resolving relative `Location` values.
|
|
123
|
+
# @api private
|
|
124
|
+
# @param response [Faraday::Response]
|
|
125
|
+
# @param max_hops [Integer]
|
|
126
|
+
# @param base_url [String]
|
|
127
|
+
# @return [Faraday::Response] the final (non-redirect) response
|
|
128
|
+
# @raise [DiscoveryError] when exceeding hops or encountering invalid/missing Location
|
|
129
|
+
def follow_redirects(response, max_hops:, base_url:)
|
|
130
|
+
hops = 0
|
|
131
|
+
current = response
|
|
132
|
+
base = base_url
|
|
133
|
+
|
|
134
|
+
while redirect_status?(current.status)
|
|
135
|
+
if hops >= max_hops
|
|
136
|
+
raise DiscoveryError.new("Too many redirects (max #{max_hops})",
|
|
137
|
+
code: 'discovery_redirect_error')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
location = location_from(current)
|
|
141
|
+
url = absolutize_location(location, base)
|
|
142
|
+
current = @conn.get(url)
|
|
143
|
+
base = url
|
|
144
|
+
hops += 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
current
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Returns true if status is an HTTP redirect.
|
|
151
|
+
# @api private
|
|
152
|
+
# @param status [Integer]
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def redirect_status?(status)
|
|
155
|
+
[301, 302, 303, 307, 308].include?(status)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Extracts and normalizes the Location header, raising when missing.
|
|
159
|
+
# @api private
|
|
160
|
+
# @param response [Faraday::Response]
|
|
161
|
+
# @return [String] absolute or relative URL string
|
|
162
|
+
# @raise [DiscoveryError]
|
|
163
|
+
def location_from(response)
|
|
164
|
+
raw = response.headers || {}
|
|
165
|
+
headers = {}
|
|
166
|
+
raw.each { |k, v| headers[k.to_s.downcase] = v }
|
|
167
|
+
location = headers['location'].to_s.strip
|
|
168
|
+
raise DiscoveryError.new('Redirect without Location header', code: 'discovery_redirect_error') if location.empty?
|
|
169
|
+
|
|
170
|
+
location
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Resolves a possibly-relative Location value to an absolute URL string.
|
|
174
|
+
# @api private
|
|
175
|
+
# @param location [String]
|
|
176
|
+
# @param base_url [String]
|
|
177
|
+
# @return [String] absolute URL
|
|
178
|
+
# @raise [DiscoveryError] when location is an invalid URI
|
|
179
|
+
def absolutize_location(location, base_url)
|
|
180
|
+
uri = URI.parse(location)
|
|
181
|
+
return location if uri.absolute?
|
|
182
|
+
|
|
183
|
+
base = URI.parse(base_url)
|
|
184
|
+
URI.join(base, location).to_s
|
|
185
|
+
rescue URI::InvalidURIError => e
|
|
186
|
+
raise DiscoveryError.new("Redirect Location is invalid: #{e.message}", code: 'discovery_redirect_error')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Parses a JSON string and maps parse errors to {DiscoveryError}.
|
|
190
|
+
# @api private
|
|
191
|
+
# @param body [String]
|
|
192
|
+
# @return [Hash]
|
|
193
|
+
# @raise [DiscoveryError]
|
|
194
|
+
def parse_json(body)
|
|
195
|
+
JSON.parse(body)
|
|
196
|
+
rescue JSON::ParserError
|
|
197
|
+
raise DiscoveryError.new('Discovery response is not valid JSON', code: 'discovery_metadata_invalid')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Validates HTTP response success status (helper, currently unused).
|
|
201
|
+
# @api private
|
|
202
|
+
# @param response [Faraday::Response]
|
|
203
|
+
# @return [void]
|
|
204
|
+
# @raise [DiscoveryError]
|
|
205
|
+
def validate_http_status!(response)
|
|
206
|
+
return if response.success?
|
|
207
|
+
|
|
208
|
+
raise DiscoveryError.new("Failed to fetch discovery document: status #{response.status}",
|
|
209
|
+
code: 'discovery_metadata_fetch_failed')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Wraps a block with network and parsing error handling and re-raising as {DiscoveryError}.
|
|
213
|
+
# @api private
|
|
214
|
+
# @yield
|
|
215
|
+
# @return [Object] the block result
|
|
216
|
+
# @raise [DiscoveryError]
|
|
217
|
+
def with_error_handling
|
|
218
|
+
yield
|
|
219
|
+
rescue Verikloak::DiscoveryError
|
|
220
|
+
# Re-raise library-specific discovery errors without altering their code/message
|
|
221
|
+
raise
|
|
222
|
+
rescue Faraday::ConnectionFailed
|
|
223
|
+
raise DiscoveryError.new('Could not connect to discovery endpoint', code: 'discovery_metadata_fetch_failed')
|
|
224
|
+
rescue Faraday::TimeoutError
|
|
225
|
+
raise DiscoveryError.new('Discovery endpoint request timed out', code: 'discovery_metadata_fetch_failed')
|
|
226
|
+
rescue Faraday::Error => e
|
|
227
|
+
raise DiscoveryError.new("Discovery request failed: #{e.message}", code: 'discovery_metadata_fetch_failed')
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
raise DiscoveryError.new("Unexpected discovery error: #{e.message}", code: 'discovery_metadata_fetch_failed')
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Ensures all required fields exist in the discovery JSON document.
|
|
233
|
+
# @api private
|
|
234
|
+
# @param json [Hash]
|
|
235
|
+
# @return [void]
|
|
236
|
+
# @raise [DiscoveryError]
|
|
237
|
+
def validate_required_fields!(json)
|
|
238
|
+
REQUIRED_FIELDS.each do |field|
|
|
239
|
+
unless json[field]
|
|
240
|
+
raise DiscoveryError.new("Discovery document is missing '#{field}'",
|
|
241
|
+
code: 'discovery_metadata_invalid')
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|