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 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
+ [![CI](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/taiyaky/verikloak/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/verikloak)](https://rubygems.org/gems/verikloak)
5
+ ![Ruby Version](https://img.shields.io/gem/rt/ruby/verikloak)
6
+ [![Downloads](https://img.shields.io/gem/dt/verikloak)](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&lt;String&gt;]
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