rack_jwt_aegis 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e776985ff922d83b9ac75821698125a565dce342be61bd4bf6012fc97dfd82c
4
- data.tar.gz: 7bbb4006689d39ea9ec47ad869b01468bbfbddfb0ecbaa430d28ac96f1fd33b7
3
+ metadata.gz: b5246ed44e15045cae4b9ec776d95b29e3f30f40008b41081c44acc61105e3de
4
+ data.tar.gz: 0d72626e7d23203a12ff37dbbf1516664f2043a52423404a9fdd43cd9ddc85ee
5
5
  SHA512:
6
- metadata.gz: 64eb785ccd161bf7f7a7e0ce2b9840007885778b47d35a5528fe586ebcc160aa2f3d356e92868f8931c0789603a78fbc1feef56d6141dbeb322611330404b77a
7
- data.tar.gz: 84f45c9c58aed4eadbcfd8a1d78d1a3e0cdd47c9f602ae9047e847738c671c0513dacfc9298207456fa969a44045ec6b8b8ff29fea3e0c29f76357aa4933ddaf
6
+ metadata.gz: b2ac0d480d1e060e36e114fccfb373c6c6602913f49f227c94d24feb112590f877146e903c3dfdc726c2dc2c130ae9f1c183a968febba522e1607e2483cfba09
7
+ data.tar.gz: 2c349695ae61b01a65fd87f8de710d85f356e29b0328a639ea5aef5548c40140aaa4395a5245fb0374caabdaa9dd11abf14c88a161275f4f4bd3b0f233bda621
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2
data/.yardopts CHANGED
@@ -1,14 +1,8 @@
1
- --output-dir doc
2
- --readme README.md
3
- --main README.md
4
- --markup=markdown
1
+ --title "RackJwtAegis Rack Middleware API Documentation""
5
2
  --protected
6
- --private
7
- --no-private
8
- --title "RackJwtAegis Rack Middleware API Documentation"
9
- --charset utf-8
10
- lib/**/*.rb
3
+ --markup markdown
4
+ --readme README.md
5
+ 'lib/**/*.rb'
11
6
  -
12
- README.md
13
- LICENSE.txt
14
- CODE_OF_CONDUCT.md
7
+ --files CHANGELOG.md LICENSE.txt CODE_OF_CONDUCT.md
8
+
data/CHANGELOG.md CHANGED
@@ -1,9 +1,103 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## Unreleased
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ## [1.2.0] - 2026-08-24
6
+
7
+ ### Added
8
+
9
+ - Added `skip_options_requests` to bypass JWT authentication for CORS preflight
10
+ requests while leaving ordinary requests protected.
11
+ - Documented the intended middleware ordering: `Rack::Cors` must run before
12
+ `RackJwtAegis::Middleware`.
13
+
14
+ ## [1.1.1] - 2026-06-13
15
+
16
+ ### 🚀 Added
17
+
18
+ #### Method-Aware Skip Routes
19
+
20
+ - Added `skip_routes` to support request-aware public route matching by path and HTTP verb.
21
+ - Kept `skip_paths` as a backward-compatible alias for all-method skips.
22
+ - Normalizes route rules once at configuration time so middleware checks stay fast per request.
23
+
24
+ #### Route Skipping Behavior
25
+
26
+ - Middleware now evaluates `METHOD + path` for skip decisions, so `POST /login` can be public while `GET /login` remains protected.
27
+ - Added support for blank verb lists to mean all methods on a route entry.
28
+ - Updated the Pink House API initializer to use the new `skip_routes` shape.
29
+
30
+ ### 🔧 Fixed
31
+
32
+ #### Simplified RBAC Cache Strategy
33
+
34
+ - **Streamlined Configuration**: Removed legacy cache configuration options to eliminate confusion
35
+ - **Removed**: `cache_store`, `cache_options`, and `cache_write_enabled` (deprecated in favor of dedicated RBAC cache stores)
36
+ - **Required**: When `rbac_enabled: true`, both `rbac_cache_store` and `permissions_cache_store` must be explicitly configured
37
+ - **Simplified**: Consistent naming with `rbac_cache_store_options` and `permissions_cache_store_options`
38
+
39
+ #### Cache Store Validation
40
+
41
+ - **Mandatory Configuration**: RBAC cache stores are now required when RBAC is enabled
42
+ - Prevents runtime errors from missing cache configuration
43
+ - Provides clear error messages during initialization: "rbac_cache_store and permissions_cache_store are required when RBAC is enabled"
44
+ - Ensures production deployments are properly configured for performance
45
+
46
+ #### Code Cleanup
47
+
48
+ - **Removed Complexity**: Eliminated dual cache mode logic and fallback mechanisms
49
+ - Simplified `RbacManager#setup_cache_adapters` method
50
+ - Removed conditional cache write logic that added complexity
51
+ - Cleaner permission checking flow with consistent cache behavior
52
+
53
+ #### Developer Experience
54
+
55
+ - **Clear Configuration**: Explicit cache store requirements eliminate guesswork
56
+ - **Better Error Messages**: Configuration validation happens at startup, not at runtime
57
+ - **Consistent Naming**: Standardized cache option parameter names across all adapters
58
+
59
+ ### 🏗️ Technical Details
60
+
61
+ #### Breaking Changes (Minor - Configuration Only)
62
+
63
+ - **Removed Configuration Options**:
64
+ - `cache_store` → Use `rbac_cache_store` and `permissions_cache_store` instead
65
+ - `cache_options` → Use `rbac_cache_store_options` and `permissions_cache_store_options` instead
66
+ - `cache_write_enabled` → Cache writing is now always enabled when RBAC is active
67
+
68
+ #### Migration Guide
69
+
70
+ **Before (v1.1.0):**
71
+ ```ruby
72
+ use RackJwtAegis::Middleware, {
73
+ jwt_secret: ENV['JWT_SECRET'],
74
+ rbac_enabled: true,
75
+ cache_store: :redis,
76
+ cache_options: { url: ENV['REDIS_URL'] },
77
+ cache_write_enabled: true
78
+ }
79
+ ```
80
+
81
+ **After (v1.1.1):**
82
+ ```ruby
83
+ use RackJwtAegis::Middleware, {
84
+ jwt_secret: ENV['JWT_SECRET'],
85
+ rbac_enabled: true,
86
+ rbac_cache_store: :redis,
87
+ rbac_cache_store_options: { url: ENV['REDIS_URL'] },
88
+ permissions_cache_store: :redis,
89
+ permissions_cache_store_options: { url: ENV['REDIS_URL'] }
90
+ }
91
+ ```
92
+
93
+ #### Benefits
94
+
95
+ - **🚀 Simpler Configuration**: No more dual cache modes or conditional logic
96
+ - **🛡️ Fail-Fast Validation**: Configuration errors caught at startup
97
+ - **📈 Better Performance**: Dedicated cache stores optimized for their specific use cases
98
+ - **🧹 Cleaner Code**: Reduced complexity in cache management logic
99
+
100
+ ---
7
101
 
8
102
  ## [1.1.0] - 2025-08-14
9
103
 
@@ -71,11 +165,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71
165
  #### Developer Experience
72
166
 
73
167
  - **Better Error Messages**: Clear configuration error descriptions
168
+
74
169
  ```ruby
75
170
  # Example error message:
76
- "RBAC permissions must be a Hash with role-id keys, not Array.
171
+ "RBAC permissions must be a Hash with role-id keys, not Array.
77
172
  Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
78
173
  ```
174
+
79
175
  - **Migration Support**: Catches common mistakes when upgrading cache format
80
176
  - **Improved Documentation**: Cleaner YARD output with better Markdown rendering
81
177
 
@@ -96,6 +192,7 @@ Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
96
192
  #### Updating RBAC Cache Format
97
193
 
98
194
  **Before (v1.0.x):**
195
+
99
196
  ```ruby
100
197
  Rails.cache.write("permissions", {
101
198
  'last_update' => Time.now.to_i,
@@ -107,6 +204,7 @@ Rails.cache.write("permissions", {
107
204
  ```
108
205
 
109
206
  **After (v1.1.0+):**
207
+
110
208
  ```ruby
111
209
  Rails.cache.write("permissions", {
112
210
  'last_update' => Time.now.to_i,
@@ -294,8 +392,8 @@ use RackJwtAegis::Middleware, {
294
392
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//,
295
393
  rbac_enabled: true,
296
394
  rbac_cache_store: :redis,
297
- permission_cache_store: :memory,
298
- user_permissions_ttl: 300,
395
+ permissions_cache_store: :memory,
396
+ cached_permissions_ttl: 300,
299
397
  cache_write_enabled: true,
300
398
  skip_paths: [/^\/health/, /^\/metrics/, /^\/api\/public/],
301
399
  custom_payload_validation: ->(payload) { payload['active'] == true },
@@ -369,6 +467,7 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
369
467
 
370
468
  **Deprecations**: None (initial release).
371
469
 
470
+ [1.1.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.1
372
471
  [1.1.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.0
373
472
  [1.0.2]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.2
374
473
  [1.0.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.1
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/rack_jwt_aegis.svg)](https://badge.fury.io/rb/rack_jwt_aegis)
4
4
  [![CI](https://github.com/kanutocd/rack_jwt_aegis/workflows/CI/badge.svg)](https://github.com/kanutocd/rack_jwt_aegis/actions)
5
5
  [![Coverage Status](https://codecov.io/gh/kanutocd/rack_jwt_aegis/branch/main/graph/badge.svg)](https://codecov.io/gh/kanutocd/rack_jwt_aegis)
6
- [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
6
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
9
  JWT authentication and authorization middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
@@ -15,7 +15,8 @@ JWT authentication and authorization middleware for hierarchical multi-tenant Ra
15
15
  - Subdomain-based tenant isolation for top-level tenants
16
16
  - URL pathname slug access control for sub-level tenants
17
17
  - **RBAC (Role-Based Access Control)** with flexible role extraction from JWT payloads
18
- - Configurable path exclusions for public endpoints
18
+ - Configurable route exclusions for public endpoints
19
+ - Optional automatic bypass for browser CORS preflight requests
19
20
  - **Flexible payload mapping** for custom JWT claim names
20
21
  - Custom payload validation
21
22
  - Debug mode for development
@@ -79,15 +80,36 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
79
80
  ### Rails Application
80
81
 
81
82
  ```ruby
82
- # config/application.rb
83
- config.middleware.insert_before 0, RackJwtAegis::Middleware, {
83
+ # config/initializers/cors.rb
84
+ Rails.application.config.middleware.insert_before 0, Rack::Cors do
85
+ allow do
86
+ origins 'https://app.example.com'
87
+ resource '*', headers: :any,
88
+ methods: %i[get post put patch delete options head]
89
+ end
90
+ end
91
+
92
+ # config/initializers/rack_jwt_aegis.rb
93
+ Rails.application.config.middleware.insert_after Rack::Cors, RackJwtAegis::Middleware, {
84
94
  jwt_secret: ENV['JWT_SECRET'],
85
95
  validate_tenant_id: true,
86
96
  tenant_id_header_name: 'X-Tenant-Id',
87
- skip_paths: ['/api/v1/login', '/health']
97
+ # Browser OPTIONS preflight requests do not carry bearer credentials.
98
+ skip_options_requests: true,
99
+ skip_routes: [
100
+ { path: '/api/v1/login', verbs: [:post] },
101
+ { path: '/health' }
102
+ ]
88
103
  }
89
104
  ```
90
105
 
106
+ `Rack::Cors` should run before `RackJwtAegis::Middleware` in the Rack stack.
107
+ It can then validate the request origin, requested method, and requested
108
+ headers and handle valid preflight requests before JWT authentication runs.
109
+ `skip_options_requests: true` is an additional middleware-level safeguard: it
110
+ skips JWT authentication for `OPTIONS` requests only. It does not make any
111
+ non-`OPTIONS` request public, and it does not replace CORS validation.
112
+
91
113
  ### Sinatra Application
92
114
 
93
115
  ```ruby
@@ -97,7 +119,10 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
97
119
  jwt_secret: ENV['JWT_SECRET'],
98
120
  validate_tenant_id: true,
99
121
  tenant_id_header_name: 'X-Tenant-Id',
100
- skip_paths: ['/login', '/health']
122
+ skip_routes: [
123
+ { path: '/login', verbs: [:post] },
124
+ { path: '/health' }
125
+ ]
101
126
  }
102
127
  ```
103
128
 
@@ -142,33 +167,33 @@ RackJwtAegis::Middleware.new(app, {
142
167
  role_ids: :role_ids,
143
168
  },
144
169
 
145
- # Path Configuration
146
- skip_paths: ['/health', '/api/v1/login'],
170
+ # Route Configuration
171
+ skip_options_requests: false, # Set true when Rack::Cors handles browser preflight requests
172
+ skip_routes: [
173
+ { path: '/health' },
174
+ { path: '/api/v1/login', verbs: [:post] }
175
+ ],
147
176
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
148
177
 
149
178
  # RBAC Configuration
150
- rbac_enabled: true, # Default: false
151
- rbac_cache_store: :redis, # Required when RBAC enabled
152
- rbac_cache_options: { url: ENV['REDIS_URL'] },
153
- user_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
154
-
155
- # Cache Store Configuration (choose one approach)
156
- # Option 1: Shared cache for both RBAC and permissions
157
- cache_store: :memory, # :memory, :redis, :memcached, :solid_cache
158
- cache_options: { url: ENV['REDIS_URL'] },
159
-
160
- # Option 2: Separate cache stores for RBAC and permissions
161
- rbac_cache_store: :redis, # For RBAC permissions data
162
- rbac_cache_options: { url: ENV['REDIS_URL'] },
163
- permission_cache_store: :memory, # For cached user permissions
164
- permission_cache_options: {},
179
+ rbac_enabled: true, # Default: false
180
+ rbac_cache_store: :redis, # Required when RBAC enabled. Default: :memory
181
+ rbac_cache_store_options: { url: ENV['REDIS_URL'] }, # Cache Store specific options. Default: {}
182
+
183
+ cached_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
184
+ permissions_cache_store: :solid_cache, # Required when RBAC enabled. Default: :memory
185
+ permissions_cache_store_options: {}, # Cache Store specific options. Default: {}
186
+
187
+ # Or can also be the same Redis instance
188
+ # permissions_cache_store: :redis, # Required when RBAC enabled. Default: :memory
189
+ # permissions_cache_store_options: { url: ENV['REDIS_URL'] },
165
190
 
166
191
  # Response Customization
167
192
  unauthorized_response: { error: 'Authentication required' },
168
193
  forbidden_response: { error: 'Access denied' },
169
194
 
170
195
  # Debugging
171
- debug_mode: Rails.env.development? # Default: false
196
+ debug_mode: Rails.env.development? # Default: when in Rails, Rails.env.development? otherwise false
172
197
  })
173
198
  ```
174
199
 
@@ -404,18 +429,19 @@ All role values are normalized to strings internally for consistent matching aga
404
429
 
405
430
  ```ruby
406
431
  # Memory cache (development/testing)
407
- config.cache_store = :memory
432
+ config.rbac_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
433
+ config.permissions_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
408
434
 
409
435
  # Redis cache
410
- config.cache_store = :redis
411
- config.cache_options = { url: ENV['REDIS_URL'] }
436
+ config.rbac_cache_store = :redis
437
+ config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
412
438
 
413
439
  # Memcached cache
414
- config.cache_store = :memcached
415
- config.cache_options = { servers: ['localhost:11211'] }
440
+ config.rbac_cache_store = :memcached
441
+ config.rbac_cache_store_options = { servers: ['localhost:11211'] }
416
442
 
417
443
  # Solid Cache (Rails 8+)
418
- config.cache_store = :solid_cache
444
+ config.rbac_cache_store = :solid_cache
419
445
  ```
420
446
 
421
447
  #### Separate Cache Stores
@@ -425,11 +451,11 @@ You can configure separate cache stores for RBAC permissions data and cached use
425
451
  ```ruby
426
452
  # Use Redis for RBAC data (shared across instances)
427
453
  config.rbac_cache_store = :redis
428
- config.rbac_cache_options = { url: ENV['REDIS_URL'] }
454
+ config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
429
455
 
430
456
  # Use memory for user permission cache (faster local access)
431
- config.permission_cache_store = :memory
432
- config.permission_cache_options = {}
457
+ config.permissions_cache_store = :memory
458
+ config.permissions_cache_store_options = {}
433
459
  ```
434
460
 
435
461
  ## RBAC Cache Format
@@ -506,7 +532,7 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
506
532
  "12345:acme-group.localhost.local/api/v1/company/sales/invoices:post": 1640995200
507
533
  }
508
534
  ```
509
- TTL configurable via `user_permissions_ttl` option (default: 30 minutes)
535
+ TTL configurable via `cached_permissions_ttl` option (default: 30 minutes)
510
536
 
511
537
  ### Cache Invalidation Strategy
512
538
 
@@ -4,13 +4,13 @@ module RackJwtAegis
4
4
  class CacheAdapter
5
5
  def self.build(store_type, options = {})
6
6
  case store_type
7
- when :memory
7
+ when :memory, :memory_store
8
8
  MemoryAdapter.new(options)
9
- when :redis
9
+ when :redis, :redis_cache_store
10
10
  RedisAdapter.new(options)
11
- when :memcached
11
+ when :memcached, :mem_cache_store
12
12
  MemcachedAdapter.new(options)
13
- when :solid_cache
13
+ when :solid_cache, :solid_cache_store
14
14
  SolidCacheAdapter.new(options)
15
15
  else
16
16
  raise ConfigurationError, "Unsupported cache store: #{store_type}"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ratomic'
4
+
5
+ module RackJwtAegis
6
+ class CircuitBreaker
7
+ STATE_FAILURE_COUNT = 'failure_count'
8
+ STATE_OPENED_AT = 'opened_at'
9
+
10
+ def initialize(failure_threshold:, cooldown_seconds:)
11
+ @failure_threshold = failure_threshold.to_i
12
+ @cooldown_seconds = cooldown_seconds.to_i
13
+ @state = Ratomic::Map.new
14
+ reset
15
+ end
16
+
17
+ def allow_request?
18
+ return true unless open?
19
+ return false unless cooldown_elapsed?
20
+
21
+ reset
22
+ true
23
+ end
24
+
25
+ def record_success
26
+ reset
27
+ end
28
+
29
+ def record_failure
30
+ failures = @state.increment(STATE_FAILURE_COUNT)
31
+ @state[STATE_OPENED_AT] = Time.now.to_f if failures >= @failure_threshold
32
+ failures
33
+ end
34
+
35
+ def open?
36
+ !@state[STATE_OPENED_AT].nil?
37
+ end
38
+
39
+ private
40
+
41
+ def reset
42
+ @state[STATE_FAILURE_COUNT] = 0
43
+ @state.delete(STATE_OPENED_AT)
44
+ end
45
+
46
+ def cooldown_elapsed?
47
+ opened_at = @state[STATE_OPENED_AT]
48
+ opened_at && (Time.now.to_f - opened_at) >= @cooldown_seconds
49
+ end
50
+ end
51
+ end