verikloak-rails 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d258ce7b00c8aed380623cc36b26669d86cf80c3c06e748bc677c4f8af93a4f1
4
+ data.tar.gz: 874e2f3c8ed2eb372d7fd4238639f4fc16e9cdd0ee6a3de7dadc57e24bbcf629
5
+ SHA512:
6
+ metadata.gz: 3e035ee3bfa25ad7c9e9eb36d1279f2ea265445c60270aa2d8a8d3fcce1757cad6003892715a1faaec22cb62148bda295522ba6c12cc3bfa7858f3d2100182eb
7
+ data.tar.gz: c94c073924c9ed1530978aadd7abd84855e7478350572ab70338b9e8cc0a8dd7b739865517dd488b347d87a23739580b09beee206bc8aeeda4afd40d0e5ab13e
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
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.0] - 2025-09-07
11
+
12
+ ### Added
13
+ - Initial release of `verikloak-rails` (Rails integration for Verikloak).
14
+ - Railtie auto-wiring via `config.verikloak.*` and installer generator `rails g verikloak:install`.
15
+ - Controller concern with authentication helpers:
16
+ - `before_action :authenticate_user!`
17
+ - `current_user_claims`, `current_subject`, `current_token`, `authenticated?`, `with_required_audience!`
18
+ - Rack middleware integration:
19
+ - Auto-inserts `Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken` and base `Verikloak::Middleware`.
20
+ - Optional BFF header promotion from `X-Forwarded-Access-Token` to `Authorization`, gated by `trust_forwarded_access_token` and `trusted_proxy_subnets` (never overwrites existing `Authorization`).
21
+ - Token sourcing priority configurable via `token_header_priority`.
22
+ - Consistent JSON error responses and statuses:
23
+ - 401/403/503 standardized; `WWW-Authenticate` header on 401.
24
+ - Optional global 500 JSON via `config.verikloak.render_500_json`.
25
+ - Optional Pundit integration: rescue `Pundit::NotAuthorizedError` to 403 JSON (`config.verikloak.rescue_pundit`).
26
+ - Request logging tags (`:request_id`, `:sub`) via `config.verikloak.logger_tags`.
27
+ - Configurable initializer: `discovery_url`, `audience`, `issuer`, `leeway`, `skip_paths`, `trust_forwarded_access_token`, `trusted_proxy_subnets`, `auto_include_controller`, `error_renderer`, and more.
28
+ - Compatibility: Ruby >= 3.1, Rails 6.1–8.x; depends on `verikloak` >= 0.1.2, < 0.2.
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,342 @@
1
+ # verikloak-rails
2
+
3
+ [![CI](https://github.com/taiyaky/verikloak-rails/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/taiyaky/verikloak-rails/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/verikloak-rails)](https://rubygems.org/gems/verikloak-rails)
5
+ ![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1-blue)
6
+ [![Downloads](https://img.shields.io/gem/dt/verikloak-rails)](https://rubygems.org/gems/verikloak-rails)
7
+
8
+ Rails integration for Verikloak.
9
+
10
+ ## Purpose
11
+ Provide drop-in, token-based authentication for Rails APIs via Verikloak (OIDC discovery and JWKS verification). It installs middleware and a controller concern to authenticate Bearer tokens, exposes helpers for claims and subject, and returns standardized JSON error responses (401/403/503) with `WWW-Authenticate` on 401. Defaults prioritize security while keeping configuration minimal.
12
+
13
+ ## Features
14
+ - Auto-wiring via Railtie (`config.verikloak.*`)
15
+ - Controller concern with `before_action :authenticate_user!`
16
+ - Helpers: `current_user_claims`, `current_subject`, `current_token`, `authenticated?`
17
+ - Exceptions → standardized JSON (401/403/503) with `WWW-Authenticate` on 401
18
+ - Log tagging (`request_id`, `sub`)
19
+ - Installer generator: `rails g verikloak:install`
20
+
21
+ ## Compatibility
22
+ - Ruby: >= 3.1
23
+ - Rails: 6.1 – 8.x
24
+ - verikloak: >= 0.1.2, < 0.2
25
+
26
+ ## Quick Start
27
+ ```bash
28
+ bundle add verikloak verikloak-rails
29
+ rails g verikloak:install
30
+ ```
31
+
32
+ Then configure `config/initializers/verikloak.rb`.
33
+
34
+ ## Controller Helpers
35
+ ### Available Methods
36
+ | Method | Purpose | Returns | On failure |
37
+ | --- | --- | --- | --- |
38
+ | `authenticate_user!` | Use as a `before_action` to require a valid Bearer token | `void` | Renders standardized 401 JSON and sets `WWW-Authenticate: Bearer` when token is absent/invalid |
39
+ | `authenticated?` | Whether verified user claims are present | `Boolean` | — |
40
+ | `current_user_claims` | Verified JWT claims (string keys) | `Hash` or `nil` | — |
41
+ | `current_subject` | Convenience accessor for `sub` claim | `String` or `nil` | — |
42
+ | `current_token` | Raw Bearer token from the request | `String` or `nil` | — |
43
+ | `with_required_audience!(*aud)` | Enforce that `aud` includes all required entries | `void` | Renders standardized 403 JSON when requirements are not met |
44
+
45
+ ### Data Sources
46
+ | Value | Rack env keys | Fallback (RequestStore) | Notes |
47
+ | --- | --- | --- | --- |
48
+ | `current_user_claims` | `verikloak.user` | `:verikloak_user` | Uses RequestStore only when available |
49
+ | `current_token` | `verikloak.token` | `:verikloak_token` | Uses RequestStore only when available |
50
+
51
+ ### Example Controller
52
+
53
+ ```ruby
54
+ class ApiController < ApplicationController
55
+ # Auto-included by default; if disabled, add explicitly:
56
+ # include Verikloak::Rails::Controller
57
+
58
+ def me
59
+ render json: { sub: current_subject, claims: current_user_claims }
60
+ end
61
+
62
+ def must_have_aud
63
+ with_required_audience!('my-api')
64
+ render json: { ok: true }
65
+ end
66
+ end
67
+ ```
68
+
69
+ ### Manual Include (disable auto-include)
70
+ If you disable auto-inclusion of the controller concern, add it manually:
71
+
72
+ ```ruby
73
+ # config/initializers/verikloak.rb
74
+ Rails.application.configure do
75
+ config.verikloak.auto_include_controller = false
76
+ end
77
+
78
+ # app/controllers/application_controller.rb
79
+ class ApplicationController < ActionController::Base
80
+ include Verikloak::Rails::Controller
81
+ end
82
+ ```
83
+
84
+ ## Middleware
85
+ ### Inserted Middlewares
86
+ | Component | Inserted after | Purpose |
87
+ | --- | --- | --- |
88
+ | `Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken` | `Rails::Rack::Logger` | Promote trusted `X-Forwarded-Access-Token` to `Authorization` and, when `Authorization` is empty, set it from a prioritized list of headers |
89
+ | `Verikloak::Middleware` | `ForwardedAccessToken` | Validate Bearer JWT (OIDC discovery + JWKS), set `verikloak.user`/`verikloak.token`, and honor `skip_paths` |
90
+
91
+ ### Header Sources and Trust
92
+ - Never overwrites an existing `Authorization` header.
93
+ - Considers `X-Forwarded-Access-Token` only when both are true: `config.verikloak.trust_forwarded_access_token` is enabled and the direct peer IP is within `config.verikloak.trusted_proxy_subnets`.
94
+ - `config.verikloak.token_header_priority` decides which env header can seed `Authorization` when it is empty. Note: `HTTP_AUTHORIZATION` is ignored as a source (it is the target header); include other headers if you need additional sources. Forwarded headers are skipped if not trusted.
95
+ - Direct peer detection prefers `REMOTE_ADDR`, falling back to the nearest proxy in `X-Forwarded-For` when needed.
96
+
97
+ ### BFF Header Promotion
98
+ When fronted by a BFF (e.g., oauth2-proxy) that injects `X-Forwarded-Access-Token`, you can promote that header to `Authorization` from trusted sources only.
99
+
100
+ Enable promotion and restrict to trusted subnets:
101
+
102
+ ```ruby
103
+ Rails.application.configure do
104
+ config.verikloak.trust_forwarded_access_token = true
105
+ config.verikloak.trusted_proxy_subnets = [
106
+ '10.0.0.0/8',
107
+ '192.168.0.0/16'
108
+ ]
109
+ end
110
+ ```
111
+
112
+ ### Reordering or Disabling (advanced)
113
+ You can adjust the stack in an initializer after the gem loads, for example:
114
+
115
+ ```ruby
116
+ Rails.application.configure do
117
+ # Remove header-promotion middleware if you never use BFF tokens
118
+ config.middleware.delete Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken
119
+
120
+ # Or move the middleware earlier/later if your stack requires it
121
+ # config.middleware.insert_before SomeOtherMiddleware, Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken,
122
+ # trust_forwarded: Verikloak::Rails.config.trust_forwarded_access_token,
123
+ # trusted_proxies: Verikloak::Rails.config.trusted_proxy_subnets,
124
+ # header_priority: Verikloak::Rails.config.token_header_priority
125
+ end
126
+ ```
127
+
128
+ ## Configuration (initializer)
129
+ ### Keys
130
+ Keys under `config.verikloak`:
131
+
132
+ | Key | Type | Description | Default |
133
+ | --- | --- | --- | --- |
134
+ | `discovery_url` | String | OIDC discovery URL | `nil` |
135
+ | `audience` | String or Array | Expected `aud` | `nil` |
136
+ | `issuer` | String | Expected `iss` | `nil` |
137
+ | `leeway` | Integer | Clock skew allowance (seconds) | `60` |
138
+ | `skip_paths` | Array<String> | Paths to skip verification | `['/up','/health','/rails/health']` |
139
+ | `trust_forwarded_access_token` | Boolean | Trust `X-Forwarded-Access-Token` from trusted proxies | `false` |
140
+ | `trusted_proxy_subnets` | Array<String or IPAddr> | Subnets allowed to be treated as trusted | `[]` (treat all as trusted; set explicit ranges in production) |
141
+ | `logger_tags` | Array<Symbol> | Tags to add to Rails logs. Supports `:request_id`, `:sub` | `[:request_id, :sub]` |
142
+ | `error_renderer` | Object responding to `render(controller, error)` | Override error rendering | built-in JSON renderer |
143
+ | `auto_include_controller` | Boolean | Auto-include controller concern | `true` |
144
+ | `render_500_json` | Boolean | Rescue `StandardError` and render JSON 500 | `false` |
145
+ | `token_header_priority` | Array<String> | Env header priority to source bearer token | `['HTTP_X_FORWARDED_ACCESS_TOKEN','HTTP_AUTHORIZATION']` |
146
+ | `rescue_pundit` | Boolean | Rescue `Pundit::NotAuthorizedError` to 403 JSON when Pundit is present | `true` |
147
+
148
+ Environment variable examples are in the generated initializer.
149
+
150
+ ### Minimum Setup
151
+ - Required: set `discovery_url` to your provider’s OIDC discovery document URL.
152
+ - Recommended: set `audience` (expected `aud`), and `issuer` when known.
153
+ - Optional: enable BFF header promotion only with explicit `trusted_proxy_subnets`.
154
+
155
+ ```ruby
156
+ # config/initializers/verikloak.rb
157
+ Rails.application.configure do
158
+ config.verikloak.discovery_url = ENV['KEYCLOAK_DISCOVERY_URL']
159
+ config.verikloak.audience = ENV.fetch('VERIKLOAK_AUDIENCE', 'rails-api')
160
+ # Optional but recommended when you know it
161
+ # config.verikloak.issuer = 'https://idp.example.com/realms/myrealm'
162
+
163
+ # Leave header promotion off unless you run a trusted BFF/proxy
164
+ # config.verikloak.trust_forwarded_access_token = false
165
+ # config.verikloak.trusted_proxy_subnets = ['10.0.0.0/8']
166
+ end
167
+ ```
168
+
169
+ Notes:
170
+ - For array-like values (`audience`, `skip_paths`, `trusted_proxy_subnets`, `token_header_priority`), prefer defining Ruby arrays in the initializer. If passing via ENV, use comma-separated strings and parse in the initializer.
171
+ - Header sourcing/trust behavior is described in “Middleware → Header Sources and Trust”.
172
+
173
+ ### Full Example (selected options)
174
+ ```ruby
175
+ # config/initializers/verikloak.rb
176
+ Rails.application.configure do
177
+ config.verikloak.discovery_url = ENV['KEYCLOAK_DISCOVERY_URL']
178
+ config.verikloak.audience = ENV.fetch('VERIKLOAK_AUDIENCE', 'rails-api')
179
+ config.verikloak.leeway = Integer(ENV.fetch('VERIKLOAK_LEEWAY', '60'))
180
+ config.verikloak.skip_paths = %w[/up /health /rails/health]
181
+
182
+ # Enable BFF header promotion only from trusted subnets
183
+ config.verikloak.trust_forwarded_access_token = ENV.fetch('VERIKLOAK_TRUST_FWD_TOKEN', 'false') == 'true'
184
+ config.verikloak.trusted_proxy_subnets = [
185
+ '10.0.0.0/8', # internal LB
186
+ # '192.168.0.0/16'
187
+ ]
188
+
189
+ config.verikloak.logger_tags = %i[request_id sub]
190
+ config.verikloak.render_500_json = ENV.fetch('VERIKLOAK_RENDER_500', 'false') == 'true'
191
+ config.verikloak.token_header_priority = %w[HTTP_X_FORWARDED_ACCESS_TOKEN HTTP_AUTHORIZATION]
192
+
193
+ # Optional Pundit rescue (403 JSON)
194
+ config.verikloak.rescue_pundit = ENV.fetch('VERIKLOAK_RESCUE_PUNDIT', 'true') == 'true'
195
+ end
196
+ ```
197
+
198
+ ### ENV Mapping
199
+
200
+ | Key | ENV var |
201
+ | --- | --- |
202
+ | `discovery_url` | `KEYCLOAK_DISCOVERY_URL` |
203
+ | `audience` | `VERIKLOAK_AUDIENCE` |
204
+ | `issuer` | `VERIKLOAK_ISSUER` |
205
+ | `leeway` | `VERIKLOAK_LEEWAY` |
206
+ | `trust_forwarded_access_token` | `VERIKLOAK_TRUST_FWD_TOKEN` |
207
+ | `render_500_json` | `VERIKLOAK_RENDER_500` |
208
+ | `rescue_pundit` | `VERIKLOAK_RESCUE_PUNDIT` |
209
+
210
+ ### Notes
211
+ - Default for `trust_forwarded_access_token` is secure (`false`). Set `trusted_proxy_subnets` before enabling.
212
+ - The middleware never overwrites an existing `Authorization` header.
213
+ - If `trusted_proxy_subnets` is empty, all peers are treated as trusted. In production, set explicit subnets or keep `trust_forwarded_access_token` disabled.
214
+
215
+ ## Errors
216
+ This gem standardizes JSON error responses and HTTP statuses. See [ERRORS.md](ERRORS.md) for details and examples.
217
+
218
+ ### Statuses
219
+ | Status | Typical code(s) | When | Headers | Body (example) |
220
+ | --- | --- | --- | --- | --- |
221
+ | 401 Unauthorized | `invalid_token`, `unauthorized` | Missing/invalid Bearer token; failed signature/expiry/issuer/audience checks | `WWW-Authenticate: Bearer` with optional `error` and `error_description` | `{ "error": "invalid_token", "message": "token expired" }` |
222
+ | 403 Forbidden | `forbidden` | Audience check failure via `with_required_audience!`; optionally `Pundit::NotAuthorizedError` when rescue is enabled | — | `{ "error": "forbidden", "message": "Required audience not satisfied" }` |
223
+ | 503 Service Unavailable | `jwks_fetch_failed`, `jwks_parse_failed`, `discovery_metadata_fetch_failed`, `discovery_metadata_invalid` | Upstream metadata/JWKS issues | — | `{ "error": "jwks_fetch_failed", "message": "..." }` |
224
+
225
+ ### Customize
226
+ Customize rendering by assigning `config.verikloak.error_renderer`.
227
+
228
+ Example: return a compact JSON shape while preserving `WWW-Authenticate` for 401.
229
+
230
+ ```ruby
231
+ class CompactErrorRenderer
232
+ def render(controller, error)
233
+ code = error.respond_to?(:code) ? error.code : 'unauthorized'
234
+ message = error.message.to_s
235
+
236
+ status = case code
237
+ when 'forbidden' then 403
238
+ when 'jwks_fetch_failed', 'jwks_parse_failed', 'discovery_metadata_fetch_failed', 'discovery_metadata_invalid' then 503
239
+ else 401
240
+ end
241
+
242
+ if status == 401
243
+ hdr = +'Bearer'
244
+ hdr << %( error="#{sanitize_quoted(code)}") if code
245
+ hdr << %( error_description="#{sanitize_quoted(message)}") if message && !message.empty?
246
+ controller.response.set_header('WWW-Authenticate', hdr)
247
+ end
248
+
249
+ controller.render json: { code: code, msg: message }, status: status
250
+ end
251
+ end
252
+
253
+ Rails.application.configure do
254
+ config.verikloak.error_renderer = CompactErrorRenderer.new
255
+ end
256
+ ```
257
+
258
+ Note: Always sanitize values placed into `WWW-Authenticate` header parameters to avoid header injection. For example:
259
+
260
+ ```ruby
261
+ class CompactErrorRenderer
262
+ private
263
+ def sanitize_quoted(val)
264
+ # Escape quotes/backslashes and strip CR/LF
265
+ val.to_s.gsub(/(["\\])/) { |m| "\\#{m}" }.gsub(/[\r\n]/, ' ')
266
+ end
267
+ end
268
+ ```
269
+
270
+ ## Optional Pundit Rescue
271
+ If the `pundit` gem is present, `Pundit::NotAuthorizedError` is rescued to a standardized 403 JSON. This is a lightweight convenience only; deeper Pundit integration (policies, helpers) is out of scope and can live in a separate plugin.
272
+
273
+ ### Toggle
274
+ Toggle with `config.verikloak.rescue_pundit` (default: true). Environment example:
275
+
276
+ ```ruby
277
+ # config/initializers/verikloak.rb
278
+ Rails.application.configure do
279
+ # Disable the built-in rescue if you handle Pundit errors yourself
280
+ config.verikloak.rescue_pundit = ENV.fetch('VERIKLOAK_RESCUE_PUNDIT', 'true') == 'true'
281
+ end
282
+ ```
283
+
284
+ ### Behavior Example
285
+ ```ruby
286
+ # When Pundit raises:
287
+ raise Pundit::NotAuthorizedError, 'forbidden'
288
+ # The concern rescues and renders:
289
+ # { error: 'forbidden', message: 'forbidden' } with status 403
290
+ ```
291
+
292
+ ## Rails 8.0/8.1 Timezone Note
293
+ Rails 8.0 shows a deprecation for the upcoming 8.1 change where `to_time` preserves the receiver timezone. This gem does not call `to_time`, but your app may. To opt in and silence the deprecation, set:
294
+ ```ruby
295
+ # config/application.rb
296
+ module YourApp
297
+ class Application < Rails::Application
298
+ config.active_support.to_time_preserves_timezone = :zone
299
+ end
300
+ end
301
+ ```
302
+
303
+ ## Development (for contributors)
304
+ Clone and install dependencies:
305
+
306
+ ```bash
307
+ git clone https://github.com/taiyaky/verikloak-rails.git
308
+ cd verikloak-rails
309
+ bundle install
310
+ ```
311
+ See **Testing** below to run specs and RuboCop. For releasing, see **Publishing**.
312
+
313
+ ## Testing
314
+ All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
315
+ See the CI badge at the top for current build status.
316
+
317
+ To run the test suite locally:
318
+
319
+ ```bash
320
+ docker compose run --rm dev rspec
321
+ docker compose run --rm dev rubocop -a
322
+ ```
323
+
324
+ ## Contributing
325
+ Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
326
+
327
+ ## Security
328
+ If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
329
+
330
+ ## License
331
+ This project is licensed under the [MIT License](LICENSE).
332
+
333
+ ## Publishing (for maintainers)
334
+ Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
335
+
336
+ ## Changelog
337
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
338
+
339
+ ## References
340
+ - verikloak-rails (this gem): https://rubygems.org/gems/verikloak-rails
341
+ - Verikloak (base gem): https://github.com/taiyaky/verikloak
342
+ - Verikloak on RubyGems: https://rubygems.org/gems/verikloak
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Verikloak
6
+ module Generators
7
+ # Rails generator that creates `config/initializers/verikloak.rb` and prints
8
+ # follow-up instructions for configuring verikloak-rails.
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ desc 'Creates an initializer for verikloak-rails and documents basic usage.'
13
+
14
+ # Create the initializer file under config/initializers.
15
+ # @return [void]
16
+ # @example
17
+ # rails g verikloak:install
18
+ def create_initializer
19
+ template 'initializer.rb.erb', 'config/initializers/verikloak.rb'
20
+ end
21
+
22
+ # Print next steps for configuring the gem.
23
+ # @return [void]
24
+ def say_next_steps
25
+ say <<~MSG
26
+ ✅ verikloak: initializer created.
27
+
28
+ Next steps:
29
+ 1) Ensure the base gem is installed: gem 'verikloak', '>= 0.1.2', '< 0.2'
30
+ 2) Set discovery_url / audience in config/initializers/verikloak.rb
31
+ 3) (Optional) If you disable auto-include, add this line to ApplicationController:
32
+ include Verikloak::Rails::Controller
33
+ MSG
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module Verikloak
6
+ module Rails
7
+ # Configuration for verikloak-rails.
8
+ #
9
+ # Controls how the Rack middleware is initialized (discovery, audience,
10
+ # issuer, leeway, skip paths) and Rails-specific behavior such as
11
+ # controller inclusion, logging tags, and error rendering.
12
+ #
13
+ # @!attribute [rw] discovery_url
14
+ # OIDC discovery document URL.
15
+ # @return [String, nil]
16
+ # @!attribute [rw] audience
17
+ # Expected audience (`aud`) claim. Accepts String or Array.
18
+ # @return [String, Array<String>, nil]
19
+ # @!attribute [rw] issuer
20
+ # Expected issuer (`iss`) claim.
21
+ # @return [String, nil]
22
+ # @!attribute [rw] leeway
23
+ # Clock skew allowance in seconds.
24
+ # @return [Integer]
25
+ # @!attribute [rw] skip_paths
26
+ # Paths to skip verification.
27
+ # @return [Array<String>]
28
+ # @!attribute [rw] trust_forwarded_access_token
29
+ # Whether to trust `X-Forwarded-Access-Token` from trusted proxies.
30
+ # @return [Boolean]
31
+ # @!attribute [r] trusted_proxy_subnets
32
+ # Trusted proxy subnets.
33
+ # @return [Array<IPAddr>]
34
+ # @!attribute [rw] logger_tags
35
+ # Log tags to include (supports :request_id, :sub).
36
+ # @return [Array<Symbol>]
37
+ # @!attribute [rw] error_renderer
38
+ # Custom error renderer object responding to `render(controller, error)`.
39
+ # @return [Object]
40
+ # @!attribute [rw] auto_include_controller
41
+ # Auto-include the controller concern into ActionController::Base.
42
+ # @return [Boolean]
43
+ # @!attribute [rw] render_500_json
44
+ # Rescue StandardError and render a JSON 500 response.
45
+ # @return [Boolean]
46
+ # @!attribute [rw] token_header_priority
47
+ # Env header keys in priority order for sourcing bearer token.
48
+ # @return [Array<String>]
49
+ class Configuration
50
+ attr_accessor :discovery_url, :audience, :issuer, :leeway, :skip_paths, :trust_forwarded_access_token,
51
+ :logger_tags, :error_renderer, :auto_include_controller, :render_500_json, :token_header_priority,
52
+ :rescue_pundit
53
+ attr_reader :trusted_proxy_subnets
54
+
55
+ def initialize
56
+ @discovery_url = nil
57
+ @audience = nil
58
+ @issuer = nil
59
+ @leeway = 60
60
+ @skip_paths = ['/up', '/health', '/rails/health']
61
+ @trust_forwarded_access_token = false
62
+ @trusted_proxy_subnets = []
63
+ @logger_tags = %i[request_id sub]
64
+ @error_renderer = Verikloak::Rails::ErrorRenderer.new
65
+ @auto_include_controller = true
66
+ @render_500_json = false
67
+ @token_header_priority = %w[HTTP_X_FORWARDED_ACCESS_TOKEN HTTP_AUTHORIZATION]
68
+ @rescue_pundit = true
69
+ end
70
+
71
+ # Assign trusted proxy subnets.
72
+ # @param list [Array<String, IPAddr>, String, IPAddr] one or more CIDRs or IPAddr instances
73
+ def trusted_proxy_subnets=(list)
74
+ @trusted_proxy_subnets = Array(list).map { |e| e.is_a?(IPAddr) ? e : IPAddr.new(e) }
75
+ end
76
+
77
+ # Options forwarded to the base Verikloak Rack middleware.
78
+ # @return [Hash]
79
+ # @example
80
+ # Verikloak::Rails.config.middleware_options
81
+ # #=> { discovery_url: 'https://example/.well-known/openid-configuration', leeway: 60, ... }
82
+ def middleware_options
83
+ {
84
+ discovery_url: discovery_url,
85
+ audience: audience,
86
+ issuer: issuer,
87
+ leeway: leeway,
88
+ skip_paths: skip_paths
89
+ }.compact
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Verikloak
6
+ module Rails
7
+ # Controller concern providing Verikloak helpers and JSON error handling.
8
+ #
9
+ # Includes `before_action :authenticate_user!`, helpers such as
10
+ # `current_user_claims`, and consistent 401/403 responses. Optionally wraps
11
+ # requests with tagged logging and a 500 JSON renderer.
12
+ module Controller
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ before_action :authenticate_user!
17
+ # Register generic error handler first so specific handlers take precedence.
18
+ if Verikloak::Rails.config.render_500_json
19
+ rescue_from StandardError do |_e|
20
+ render json: { error: 'internal_server_error', message: 'An unexpected error occurred' },
21
+ status: :internal_server_error
22
+ end
23
+ end
24
+ if defined?(::Pundit::NotAuthorizedError) && Verikloak::Rails.config.rescue_pundit
25
+ rescue_from ::Pundit::NotAuthorizedError do |e|
26
+ render json: { error: 'forbidden', message: e.message }, status: :forbidden
27
+ end
28
+ end
29
+ rescue_from ::Verikloak::Error do |e|
30
+ Verikloak::Rails.config.error_renderer.render(self, e)
31
+ end
32
+ around_action :_verikloak_tag_logs
33
+ end
34
+
35
+ # Ensures a user is authenticated, otherwise renders a JSON 401 response.
36
+ #
37
+ # @return [void]
38
+ # @example In a controller
39
+ # class ApiController < ApplicationController
40
+ # before_action :authenticate_user!
41
+ # end
42
+ def authenticate_user!
43
+ return if authenticated?
44
+
45
+ e = begin
46
+ ::Verikloak::Error.new('unauthorized')
47
+ rescue StandardError
48
+ StandardError.new('Unauthorized')
49
+ end
50
+ Verikloak::Rails.config.error_renderer.render(self, e)
51
+ end
52
+
53
+ # Whether the request has verified user claims.
54
+ # @return [Boolean]
55
+ def authenticated? = current_user_claims.present?
56
+
57
+ # The verified JWT claims for the current user.
58
+ # Prefer Rack env; fall back to RequestStore when available.
59
+ # @return [Hash, nil]
60
+ def current_user_claims
61
+ env_claims = request.env['verikloak.user']
62
+ return env_claims unless env_claims.nil?
63
+ return ::RequestStore.store[:verikloak_user] if defined?(::RequestStore) && ::RequestStore.respond_to?(:store)
64
+
65
+ nil
66
+ end
67
+
68
+ # The raw bearer token used for the current request.
69
+ # Prefer Rack env; fall back to RequestStore when available.
70
+ # @return [String, nil]
71
+ def current_token
72
+ env_token = request.env['verikloak.token']
73
+ return env_token unless env_token.nil?
74
+ return ::RequestStore.store[:verikloak_token] if defined?(::RequestStore) && ::RequestStore.respond_to?(:store)
75
+
76
+ nil
77
+ end
78
+
79
+ # The `sub` (subject) claim from the current user claims.
80
+ # @return [String, nil]
81
+ def current_subject = current_user_claims && current_user_claims['sub']
82
+
83
+ # Enforces that the current user has all required audiences.
84
+ #
85
+ # @param required [Array<String>] one or more audiences to require
86
+ # @return [void]
87
+ # @example
88
+ # with_required_audience!('my-api', 'payments')
89
+ def with_required_audience!(*required)
90
+ aud = Array(current_user_claims&.dig('aud'))
91
+ return if required.flatten.all? { |r| aud.include?(r) }
92
+
93
+ render json: { error: 'forbidden', message: 'Required audience not satisfied' }, status: :forbidden
94
+ end
95
+
96
+ private
97
+
98
+ # Wraps the request in tagged logs for request ID and subject when available.
99
+ # @yieldreturn [Object] result of the block
100
+ # @return [Object]
101
+ def _verikloak_tag_logs(&)
102
+ tags = []
103
+ if Verikloak::Rails.config.logger_tags.include?(:request_id)
104
+ rid = request.request_id || request.headers['X-Request-Id']
105
+ tags << "req:#{rid}" if rid
106
+ end
107
+ tags << "sub:#{current_subject}" if Verikloak::Rails.config.logger_tags.include?(:sub) && current_subject
108
+ if ::Rails.logger.respond_to?(:tagged) && tags.any?
109
+ ::Rails.logger.tagged(*tags, &)
110
+ else
111
+ yield
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Rails
5
+ # Renders JSON errors for authentication/authorization failures.
6
+ #
7
+ # When status is 401, adds a `WWW-Authenticate: Bearer` header including
8
+ # `error` and `error_description` fields when available.
9
+ class ErrorRenderer
10
+ DEFAULT_STATUS_MAP = {
11
+ 'invalid_token' => 401,
12
+ 'unauthorized' => 401,
13
+ 'forbidden' => 403,
14
+ 'jwks_fetch_failed' => 503,
15
+ 'jwks_parse_failed' => 503,
16
+ 'discovery_metadata_fetch_failed' => 503,
17
+ 'discovery_metadata_invalid' => 503
18
+ }.freeze
19
+
20
+ # Render an error as JSON, adding `WWW-Authenticate` when appropriate.
21
+ #
22
+ # @param controller [#response,#render] a Rails controller instance
23
+ # @param error [Exception] the error to render
24
+ # @return [void]
25
+ # @example
26
+ # begin
27
+ # do_auth!
28
+ # rescue Verikloak::Error => e
29
+ # Verikloak::Rails.config.error_renderer.render(self, e)
30
+ # end
31
+ def render(controller, error)
32
+ code, message = extract_code_message(error)
33
+ status = status_for(error, code)
34
+ headers = {}
35
+ if status == 401
36
+ hdr = +'Bearer'
37
+ hdr << %( error="#{sanitize_quoted(code)}") if code
38
+ hdr << %( error_description="#{sanitize_quoted(message)}") if message
39
+ headers['WWW-Authenticate'] = hdr
40
+ end
41
+ headers.each { |k, v| controller.response.set_header(k, v) }
42
+ controller.render json: { error: code || 'unauthorized', message: message }, status: status
43
+ end
44
+
45
+ private
46
+
47
+ # Extract error code and message from a given exception.
48
+ # @param error [Exception]
49
+ # @return [Array<(String, String)>]
50
+ def extract_code_message(error)
51
+ code = if error.respond_to?(:code) && error.code
52
+ error.code
53
+ else
54
+ error.class.name.split('::').last.gsub(/Error$/, '').downcase
55
+ end
56
+ [code, error.message.to_s]
57
+ end
58
+
59
+ # Map an error to an HTTP status code.
60
+ # @param error [Exception]
61
+ # @param code [String, nil]
62
+ # @return [Integer]
63
+ def status_for(error, code)
64
+ if error.is_a?(::Verikloak::Error)
65
+ DEFAULT_STATUS_MAP[code] || 401
66
+ else
67
+ 401
68
+ end
69
+ end
70
+
71
+ # Sanitize a value for inclusion inside a quoted HTTP header parameter.
72
+ # Escapes quotes and backslashes, and strips CR/LF to prevent header injection.
73
+ # Why block replacement? String replacements like '\\1' are parsed as
74
+ # backreferences/escapes in Ruby, making precise escaping error‑prone.
75
+ # The block receives the literal match and we return it prefixed with a
76
+ # backslash, guaranteeing predictable escaping for both " and \\.
77
+ # @param val [String]
78
+ # @return [String]
79
+ def sanitize_quoted(val)
80
+ val.to_s.gsub(/(["\\])/) { |m| "\\#{m}" }.gsub(/[\r\n]/, ' ')
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module Verikloak
6
+ module Rails
7
+ # Internal namespace for Rack middleware glue.
8
+ module MiddlewareIntegration
9
+ # Promotes forwarded access tokens to Authorization when trusted.
10
+ #
11
+ # - Optionally trusts `X-Forwarded-Access-Token` from configured subnets
12
+ # - Never overwrites an existing `Authorization` header
13
+ # - Can derive the token from a prioritized list of headers
14
+ class ForwardedAccessToken
15
+ # Initialize the middleware.
16
+ #
17
+ # @param app [#call] next Rack app
18
+ # @param trust_forwarded [Boolean] whether to trust forwarded access tokens
19
+ # @param trusted_proxies [Array<IPAddr>, Array<String>] subnets considered trusted
20
+ # @param header_priority [Array<String>] env header keys to search, in order
21
+ def initialize(app, trust_forwarded:, trusted_proxies:,
22
+ header_priority: %w[HTTP_X_FORWARDED_ACCESS_TOKEN HTTP_AUTHORIZATION])
23
+ @app = app
24
+ @trust_forwarded = trust_forwarded
25
+ @trusted_proxies = Array(trusted_proxies)
26
+ @header_priority = header_priority
27
+ end
28
+
29
+ # Rack entry point: possibly promote a token and pass to the next app.
30
+ #
31
+ # @param env [Hash] Rack environment
32
+ # @return [Array(Integer, Hash, #each)] Rack response triple from downstream app
33
+ # @example Promote forwarded token
34
+ # env = { 'REMOTE_ADDR' => '10.0.0.1', 'HTTP_X_FORWARDED_ACCESS_TOKEN' => 'abc' }
35
+ # status, headers, body = middleware.call(env)
36
+ def call(env)
37
+ promote_forwarded_if_trusted(env)
38
+ first = resolve_first_token_header(env)
39
+ set_authorization_from(env, first) if first
40
+ @app.call(env)
41
+ end
42
+
43
+ private
44
+
45
+ # Promote X-Forwarded-Access-Token to Authorization when trusted.
46
+ # @param env [Hash]
47
+ # @return [void]
48
+ def promote_forwarded_if_trusted(env)
49
+ return unless @trust_forwarded && from_trusted_proxy?(env)
50
+
51
+ forwarded = env['HTTP_X_FORWARDED_ACCESS_TOKEN']
52
+ return if forwarded.to_s.empty?
53
+ return unless env['HTTP_AUTHORIZATION'].to_s.empty?
54
+
55
+ env['HTTP_AUTHORIZATION'] = ensure_bearer(forwarded)
56
+ end
57
+
58
+ # Resolve the first header key from which to source a bearer token.
59
+ # Respects trust policy for forwarded tokens and never returns
60
+ # 'HTTP_AUTHORIZATION'.
61
+ #
62
+ # @param env [Hash]
63
+ # @return [String, nil]
64
+ def resolve_first_token_header(env)
65
+ candidates = @header_priority.dup
66
+ candidates -= ['HTTP_X_FORWARDED_ACCESS_TOKEN'] unless @trust_forwarded && from_trusted_proxy?(env)
67
+ candidates.find { |k| (val = env[k]) && !val.to_s.empty? && k != 'HTTP_AUTHORIZATION' }
68
+ end
69
+
70
+ # Set Authorization header from the given env header key.
71
+ # @param env [Hash]
72
+ # @param header_key [String]
73
+ # @return [void]
74
+ def set_authorization_from(env, header_key)
75
+ token = env[header_key]
76
+ env['HTTP_AUTHORIZATION'] ||= ensure_bearer(token)
77
+ end
78
+
79
+ # Ensure the token string is prefixed with 'Bearer '.
80
+ # @param token [String]
81
+ # @return [String]
82
+ def ensure_bearer(token)
83
+ token.start_with?('Bearer') ? token : "Bearer #{token}"
84
+ end
85
+
86
+ # Whether the request originates from a trusted proxy subnet.
87
+ # @param env [Hash]
88
+ # @return [Boolean]
89
+ def from_trusted_proxy?(env)
90
+ return true if @trusted_proxies.empty?
91
+
92
+ # Prefer REMOTE_ADDR (direct peer). Fallback to the nearest proxy from X-Forwarded-For if present.
93
+ ip = (env['REMOTE_ADDR'] || '').to_s.strip
94
+ ip = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').last.to_s.strip if ip.empty? && env['HTTP_X_FORWARDED_FOR']
95
+ return false if ip.empty?
96
+
97
+ begin
98
+ req_ip = IPAddr.new(ip)
99
+ @trusted_proxies.any? { |subnet| subnet.include?(req_ip) }
100
+ rescue IPAddr::InvalidAddressError
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+ require 'verikloak/middleware'
5
+
6
+ module Verikloak
7
+ module Rails
8
+ # Hooks verikloak-rails into a Rails application lifecycle.
9
+ #
10
+ # - Applies configuration from `config.verikloak`
11
+ # - Inserts middleware (`ForwardedAccessToken`, then `Verikloak::Middleware`)
12
+ # - Auto-includes controller concern when enabled
13
+ class Railtie < ::Rails::Railtie
14
+ config.verikloak = ActiveSupport::OrderedOptions.new
15
+
16
+ # Apply configuration and insert middleware.
17
+ # @return [void]
18
+ initializer 'verikloak.configure' do |app|
19
+ Verikloak::Rails.configure do |c|
20
+ rails_cfg = app.config.verikloak
21
+ %i[discovery_url audience issuer leeway skip_paths trust_forwarded_access_token
22
+ trusted_proxy_subnets logger_tags error_renderer auto_include_controller
23
+ render_500_json token_header_priority rescue_pundit].each do |key|
24
+ c.send("#{key}=", rails_cfg[key]) if rails_cfg.key?(key)
25
+ end
26
+ end
27
+ app.middleware.insert_after ::Rails::Rack::Logger,
28
+ Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken,
29
+ trust_forwarded: Verikloak::Rails.config.trust_forwarded_access_token,
30
+ trusted_proxies: Verikloak::Rails.config.trusted_proxy_subnets,
31
+ header_priority: Verikloak::Rails.config.token_header_priority
32
+ app.middleware.insert_after Verikloak::Rails::MiddlewareIntegration::ForwardedAccessToken,
33
+ ::Verikloak::Middleware,
34
+ **Verikloak::Rails.config.middleware_options
35
+ end
36
+
37
+ # Optionally include the controller concern when ActionController loads.
38
+ # @return [void]
39
+ initializer 'verikloak.controller' do |_app|
40
+ ActiveSupport.on_load(:action_controller_base) do
41
+ include Verikloak::Rails::Controller if Verikloak::Rails.config.auto_include_controller
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Rails
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'verikloak/rails/version'
4
+ require 'verikloak/rails/configuration'
5
+ require 'verikloak/rails/error_renderer'
6
+ require 'verikloak/rails/controller'
7
+ require 'verikloak/rails/middleware_integration'
8
+ require 'verikloak/rails/railtie'
9
+
10
+ module Verikloak
11
+ # Rails integration surface for Verikloak.
12
+ #
13
+ # Exposes configuration and Railtie hooks to wire middleware and controller
14
+ # helpers into a Rails application.
15
+ module Rails
16
+ class << self
17
+ # Global configuration object for verikloak-rails.
18
+ #
19
+ # @return [Verikloak::Rails::Configuration]
20
+ # @example Read current leeway
21
+ # Verikloak::Rails.config.leeway #=> 60
22
+ def config
23
+ @config ||= Configuration.new
24
+ end
25
+
26
+ # Configure verikloak-rails.
27
+ #
28
+ # @yieldparam [Verikloak::Rails::Configuration] config
29
+ # @return [void]
30
+ # @example
31
+ # Verikloak::Rails.configure do |c|
32
+ # c.discovery_url = ENV['KEYCLOAK_DISCOVERY_URL']
33
+ # c.audience = 'rails-api'
34
+ # c.leeway = 30
35
+ # end
36
+ def configure
37
+ yield(config)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'verikloak/rails'
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verikloak-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - taiyaky
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: verikloak
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 0.1.2
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '0.2'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 0.1.2
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '0.2'
52
+ description: 'Rails integration for Verikloak: auto middleware, helpers, JSON errors,
53
+ BFF header support.'
54
+ executables: []
55
+ extensions: []
56
+ extra_rdoc_files: []
57
+ files:
58
+ - CHANGELOG.md
59
+ - LICENSE
60
+ - README.md
61
+ - lib/generators/verikloak/install/install_generator.rb
62
+ - lib/verikloak-rails.rb
63
+ - lib/verikloak/rails.rb
64
+ - lib/verikloak/rails/configuration.rb
65
+ - lib/verikloak/rails/controller.rb
66
+ - lib/verikloak/rails/error_renderer.rb
67
+ - lib/verikloak/rails/middleware_integration.rb
68
+ - lib/verikloak/rails/railtie.rb
69
+ - lib/verikloak/rails/version.rb
70
+ homepage: https://github.com/taiyaky/verikloak-rails
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ source_code_uri: https://github.com/taiyaky/verikloak-rails
75
+ changelog_uri: https://github.com/taiyaky/verikloak-rails/blob/main/CHANGELOG.md
76
+ bug_tracker_uri: https://github.com/taiyaky/verikloak-rails/issues
77
+ documentation_uri: https://rubydoc.info/gems/verikloak-rails/0.1.0
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: Rails integration for Verikloak (Keycloak JWT via Rack middleware)
96
+ test_files: []