rack_jwt_aegis 1.1.0 → 1.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 +4 -4
- data/.ruby-version +1 -0
- data/.yardopts +6 -12
- data/CHANGELOG.md +96 -6
- data/README.md +24 -27
- data/lib/rack_jwt_aegis/cache_adapter.rb +4 -4
- data/lib/rack_jwt_aegis/circuit_breaker.rb +51 -0
- data/lib/rack_jwt_aegis/configuration.rb +204 -67
- data/lib/rack_jwt_aegis/jwt_validator.rb +22 -0
- data/lib/rack_jwt_aegis/middleware.rb +40 -7
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +49 -2
- data/lib/rack_jwt_aegis/rbac_manager.rb +28 -60
- data/lib/rack_jwt_aegis/request_context.rb +14 -1
- data/lib/rack_jwt_aegis/version.rb +1 -1
- data/lib/rack_jwt_aegis.rb +2 -3
- metadata +23 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 355b1a9e875355d5a3c5c7ca9c83dd24af066e1c69e89c9030fab8bb2c5a3f71
|
|
4
|
+
data.tar.gz: e151caf3879396e067aaf2c7f5ba0228fece3de07c5bdf41ac3df3e37d271ab9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9709c05464dade807e95a135d6c5fe7d4e301127c4ea23209086298bc3078882cd1c5454262b1bc1759d1b48b27ac7cd630690e735a210f354bbb20fea808e8
|
|
7
|
+
data.tar.gz: 505983dd1f954880f7ad1a9a6e33beaf3ff83734c5034f90f5515d1ac0d48bac7c6b4e75842aeb7860695bf10efd7aba24c012cafd97b7e1089270d853c0bb23
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.2
|
data/.yardopts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
--
|
|
2
|
-
--readme README.md
|
|
3
|
-
--main README.md
|
|
4
|
-
--markup=markdown
|
|
1
|
+
--title "RackJwtAegis Rack Middleware API Documentation""
|
|
5
2
|
--protected
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
|
|
9
|
-
--charset utf-8
|
|
10
|
-
lib/**/*.rb
|
|
3
|
+
--markup markdown
|
|
4
|
+
--readme README.md
|
|
5
|
+
'lib/**/*.rb'
|
|
11
6
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
CODE_OF_CONDUCT.md
|
|
7
|
+
--files CHANGELOG.md LICENSE.txt CODE_OF_CONDUCT.md
|
|
8
|
+
|
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,94 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Unreleased
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
## [1.1.1] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### 🚀 Added
|
|
8
|
+
|
|
9
|
+
#### Method-Aware Skip Routes
|
|
10
|
+
|
|
11
|
+
- Added `skip_routes` to support request-aware public route matching by path and HTTP verb.
|
|
12
|
+
- Kept `skip_paths` as a backward-compatible alias for all-method skips.
|
|
13
|
+
- Normalizes route rules once at configuration time so middleware checks stay fast per request.
|
|
14
|
+
|
|
15
|
+
#### Route Skipping Behavior
|
|
16
|
+
|
|
17
|
+
- Middleware now evaluates `METHOD + path` for skip decisions, so `POST /login` can be public while `GET /login` remains protected.
|
|
18
|
+
- Added support for blank verb lists to mean all methods on a route entry.
|
|
19
|
+
- Updated the Pink House API initializer to use the new `skip_routes` shape.
|
|
20
|
+
|
|
21
|
+
### 🔧 Fixed
|
|
22
|
+
|
|
23
|
+
#### Simplified RBAC Cache Strategy
|
|
24
|
+
|
|
25
|
+
- **Streamlined Configuration**: Removed legacy cache configuration options to eliminate confusion
|
|
26
|
+
- **Removed**: `cache_store`, `cache_options`, and `cache_write_enabled` (deprecated in favor of dedicated RBAC cache stores)
|
|
27
|
+
- **Required**: When `rbac_enabled: true`, both `rbac_cache_store` and `permissions_cache_store` must be explicitly configured
|
|
28
|
+
- **Simplified**: Consistent naming with `rbac_cache_store_options` and `permissions_cache_store_options`
|
|
29
|
+
|
|
30
|
+
#### Cache Store Validation
|
|
31
|
+
|
|
32
|
+
- **Mandatory Configuration**: RBAC cache stores are now required when RBAC is enabled
|
|
33
|
+
- Prevents runtime errors from missing cache configuration
|
|
34
|
+
- Provides clear error messages during initialization: "rbac_cache_store and permissions_cache_store are required when RBAC is enabled"
|
|
35
|
+
- Ensures production deployments are properly configured for performance
|
|
36
|
+
|
|
37
|
+
#### Code Cleanup
|
|
38
|
+
|
|
39
|
+
- **Removed Complexity**: Eliminated dual cache mode logic and fallback mechanisms
|
|
40
|
+
- Simplified `RbacManager#setup_cache_adapters` method
|
|
41
|
+
- Removed conditional cache write logic that added complexity
|
|
42
|
+
- Cleaner permission checking flow with consistent cache behavior
|
|
43
|
+
|
|
44
|
+
#### Developer Experience
|
|
45
|
+
|
|
46
|
+
- **Clear Configuration**: Explicit cache store requirements eliminate guesswork
|
|
47
|
+
- **Better Error Messages**: Configuration validation happens at startup, not at runtime
|
|
48
|
+
- **Consistent Naming**: Standardized cache option parameter names across all adapters
|
|
49
|
+
|
|
50
|
+
### 🏗️ Technical Details
|
|
51
|
+
|
|
52
|
+
#### Breaking Changes (Minor - Configuration Only)
|
|
53
|
+
|
|
54
|
+
- **Removed Configuration Options**:
|
|
55
|
+
- `cache_store` → Use `rbac_cache_store` and `permissions_cache_store` instead
|
|
56
|
+
- `cache_options` → Use `rbac_cache_store_options` and `permissions_cache_store_options` instead
|
|
57
|
+
- `cache_write_enabled` → Cache writing is now always enabled when RBAC is active
|
|
58
|
+
|
|
59
|
+
#### Migration Guide
|
|
60
|
+
|
|
61
|
+
**Before (v1.1.0):**
|
|
62
|
+
```ruby
|
|
63
|
+
use RackJwtAegis::Middleware, {
|
|
64
|
+
jwt_secret: ENV['JWT_SECRET'],
|
|
65
|
+
rbac_enabled: true,
|
|
66
|
+
cache_store: :redis,
|
|
67
|
+
cache_options: { url: ENV['REDIS_URL'] },
|
|
68
|
+
cache_write_enabled: true
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**After (v1.1.1):**
|
|
73
|
+
```ruby
|
|
74
|
+
use RackJwtAegis::Middleware, {
|
|
75
|
+
jwt_secret: ENV['JWT_SECRET'],
|
|
76
|
+
rbac_enabled: true,
|
|
77
|
+
rbac_cache_store: :redis,
|
|
78
|
+
rbac_cache_store_options: { url: ENV['REDIS_URL'] },
|
|
79
|
+
permissions_cache_store: :redis,
|
|
80
|
+
permissions_cache_store_options: { url: ENV['REDIS_URL'] }
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Benefits
|
|
85
|
+
|
|
86
|
+
- **🚀 Simpler Configuration**: No more dual cache modes or conditional logic
|
|
87
|
+
- **🛡️ Fail-Fast Validation**: Configuration errors caught at startup
|
|
88
|
+
- **📈 Better Performance**: Dedicated cache stores optimized for their specific use cases
|
|
89
|
+
- **🧹 Cleaner Code**: Reduced complexity in cache management logic
|
|
90
|
+
|
|
91
|
+
---
|
|
7
92
|
|
|
8
93
|
## [1.1.0] - 2025-08-14
|
|
9
94
|
|
|
@@ -71,11 +156,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
71
156
|
#### Developer Experience
|
|
72
157
|
|
|
73
158
|
- **Better Error Messages**: Clear configuration error descriptions
|
|
159
|
+
|
|
74
160
|
```ruby
|
|
75
161
|
# Example error message:
|
|
76
|
-
"RBAC permissions must be a Hash with role-id keys, not Array.
|
|
162
|
+
"RBAC permissions must be a Hash with role-id keys, not Array.
|
|
77
163
|
Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
|
|
78
164
|
```
|
|
165
|
+
|
|
79
166
|
- **Migration Support**: Catches common mistakes when upgrading cache format
|
|
80
167
|
- **Improved Documentation**: Cleaner YARD output with better Markdown rendering
|
|
81
168
|
|
|
@@ -96,6 +183,7 @@ Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
|
|
|
96
183
|
#### Updating RBAC Cache Format
|
|
97
184
|
|
|
98
185
|
**Before (v1.0.x):**
|
|
186
|
+
|
|
99
187
|
```ruby
|
|
100
188
|
Rails.cache.write("permissions", {
|
|
101
189
|
'last_update' => Time.now.to_i,
|
|
@@ -107,6 +195,7 @@ Rails.cache.write("permissions", {
|
|
|
107
195
|
```
|
|
108
196
|
|
|
109
197
|
**After (v1.1.0+):**
|
|
198
|
+
|
|
110
199
|
```ruby
|
|
111
200
|
Rails.cache.write("permissions", {
|
|
112
201
|
'last_update' => Time.now.to_i,
|
|
@@ -294,8 +383,8 @@ use RackJwtAegis::Middleware, {
|
|
|
294
383
|
pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//,
|
|
295
384
|
rbac_enabled: true,
|
|
296
385
|
rbac_cache_store: :redis,
|
|
297
|
-
|
|
298
|
-
|
|
386
|
+
permissions_cache_store: :memory,
|
|
387
|
+
cached_permissions_ttl: 300,
|
|
299
388
|
cache_write_enabled: true,
|
|
300
389
|
skip_paths: [/^\/health/, /^\/metrics/, /^\/api\/public/],
|
|
301
390
|
custom_payload_validation: ->(payload) { payload['active'] == true },
|
|
@@ -369,6 +458,7 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
|
|
|
369
458
|
|
|
370
459
|
**Deprecations**: None (initial release).
|
|
371
460
|
|
|
461
|
+
[1.1.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.1
|
|
372
462
|
[1.1.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.0
|
|
373
463
|
[1.0.2]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.2
|
|
374
464
|
[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
|
[](https://badge.fury.io/rb/rack_jwt_aegis)
|
|
4
4
|
[](https://github.com/kanutocd/rack_jwt_aegis/actions)
|
|
5
5
|
[](https://codecov.io/gh/kanutocd/rack_jwt_aegis)
|
|
6
|
-
[](https://www.ruby-lang.org/en/)
|
|
7
7
|
[](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.
|
|
@@ -147,28 +147,24 @@ RackJwtAegis::Middleware.new(app, {
|
|
|
147
147
|
pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
|
|
148
148
|
|
|
149
149
|
# RBAC Configuration
|
|
150
|
-
rbac_enabled: true,
|
|
151
|
-
rbac_cache_store: :redis,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#
|
|
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: {},
|
|
150
|
+
rbac_enabled: true, # Default: false
|
|
151
|
+
rbac_cache_store: :redis, # Required when RBAC enabled. Default: :memory
|
|
152
|
+
rbac_cache_store_options: { url: ENV['REDIS_URL'] }, # Cache Store specific options. Default: {}
|
|
153
|
+
|
|
154
|
+
cached_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
|
|
155
|
+
permissions_cache_store: :solid_cache, # Required when RBAC enabled. Default: :memory
|
|
156
|
+
permissions_cache_store_options: {}, # Cache Store specific options. Default: {}
|
|
157
|
+
|
|
158
|
+
# Or can also be the same Redis instance
|
|
159
|
+
# permissions_cache_store: :redis, # Required when RBAC enabled. Default: :memory
|
|
160
|
+
# permissions_cache_store_options: { url: ENV['REDIS_URL'] },
|
|
165
161
|
|
|
166
162
|
# Response Customization
|
|
167
163
|
unauthorized_response: { error: 'Authentication required' },
|
|
168
164
|
forbidden_response: { error: 'Access denied' },
|
|
169
165
|
|
|
170
166
|
# Debugging
|
|
171
|
-
debug_mode: Rails.env.development? # Default: false
|
|
167
|
+
debug_mode: Rails.env.development? # Default: when in Rails, Rails.env.development? otherwise false
|
|
172
168
|
})
|
|
173
169
|
```
|
|
174
170
|
|
|
@@ -404,18 +400,19 @@ All role values are normalized to strings internally for consistent matching aga
|
|
|
404
400
|
|
|
405
401
|
```ruby
|
|
406
402
|
# Memory cache (development/testing)
|
|
407
|
-
config.
|
|
403
|
+
config.rbac_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
|
|
404
|
+
config.permissions_cache_store = :memory # Default. When in Rails, it will default to Rails.application.config.cache_store
|
|
408
405
|
|
|
409
406
|
# Redis cache
|
|
410
|
-
config.
|
|
411
|
-
config.
|
|
407
|
+
config.rbac_cache_store = :redis
|
|
408
|
+
config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
|
|
412
409
|
|
|
413
410
|
# Memcached cache
|
|
414
|
-
config.
|
|
415
|
-
config.
|
|
411
|
+
config.rbac_cache_store = :memcached
|
|
412
|
+
config.rbac_cache_store_options = { servers: ['localhost:11211'] }
|
|
416
413
|
|
|
417
414
|
# Solid Cache (Rails 8+)
|
|
418
|
-
config.
|
|
415
|
+
config.rbac_cache_store = :solid_cache
|
|
419
416
|
```
|
|
420
417
|
|
|
421
418
|
#### Separate Cache Stores
|
|
@@ -425,11 +422,11 @@ You can configure separate cache stores for RBAC permissions data and cached use
|
|
|
425
422
|
```ruby
|
|
426
423
|
# Use Redis for RBAC data (shared across instances)
|
|
427
424
|
config.rbac_cache_store = :redis
|
|
428
|
-
config.
|
|
425
|
+
config.rbac_cache_store_options = { url: ENV['REDIS_URL'] }
|
|
429
426
|
|
|
430
427
|
# Use memory for user permission cache (faster local access)
|
|
431
|
-
config.
|
|
432
|
-
config.
|
|
428
|
+
config.permissions_cache_store = :memory
|
|
429
|
+
config.permissions_cache_store_options = {}
|
|
433
430
|
```
|
|
434
431
|
|
|
435
432
|
## RBAC Cache Format
|
|
@@ -506,7 +503,7 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
|
|
|
506
503
|
"12345:acme-group.localhost.local/api/v1/company/sales/invoices:post": 1640995200
|
|
507
504
|
}
|
|
508
505
|
```
|
|
509
|
-
TTL configurable via `
|
|
506
|
+
TTL configurable via `cached_permissions_ttl` option (default: 30 minutes)
|
|
510
507
|
|
|
511
508
|
### Cache Invalidation Strategy
|
|
512
509
|
|
|
@@ -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
|
|
@@ -19,8 +19,6 @@ module RackJwtAegis
|
|
|
19
19
|
# validate_subdomain: true,
|
|
20
20
|
# validate_pathname_slug: true,
|
|
21
21
|
# rbac_enabled: true,
|
|
22
|
-
# cache_store: :redis,
|
|
23
|
-
# cache_write_enabled: true,
|
|
24
22
|
# skip_paths: ['/health', '/api/public/*'],
|
|
25
23
|
# debug_mode: Rails.env.development?
|
|
26
24
|
# )
|
|
@@ -53,10 +51,30 @@ module RackJwtAegis
|
|
|
53
51
|
# @return [Boolean] true if tenant id validation is enabled
|
|
54
52
|
attr_accessor :validate_tenant_id
|
|
55
53
|
|
|
54
|
+
# Whether authenticated requests must include all identity/tenant headers
|
|
55
|
+
# @return [Boolean] true if strict authenticated request headers are required
|
|
56
|
+
attr_accessor :require_authentication_headers
|
|
57
|
+
|
|
58
|
+
# Whether JWTs must include expiration-related claims
|
|
59
|
+
# @return [Boolean] true if exp and iat claims are required
|
|
60
|
+
attr_accessor :require_expiration_claims
|
|
61
|
+
|
|
56
62
|
# Whether RBAC (Role-Based Access Control) is enabled
|
|
57
63
|
# @return [Boolean] true if RBAC is enabled
|
|
58
64
|
attr_accessor :rbac_enabled
|
|
59
65
|
|
|
66
|
+
# Whether unexpected errors should trip a fail-fast circuit breaker
|
|
67
|
+
# @return [Boolean] true if circuit breaker is enabled
|
|
68
|
+
attr_accessor :circuit_breaker_enabled
|
|
69
|
+
|
|
70
|
+
# Number of unexpected failures before the circuit opens
|
|
71
|
+
# @return [Integer] failure threshold
|
|
72
|
+
attr_accessor :circuit_breaker_failure_threshold
|
|
73
|
+
|
|
74
|
+
# Seconds to fail fast before allowing another request attempt
|
|
75
|
+
# @return [Integer] cooldown seconds
|
|
76
|
+
attr_accessor :circuit_breaker_cooldown_seconds
|
|
77
|
+
|
|
60
78
|
# @!endgroup
|
|
61
79
|
|
|
62
80
|
# @!group Multi-tenant Settings
|
|
@@ -65,6 +83,14 @@ module RackJwtAegis
|
|
|
65
83
|
# @return [String] the tenant ID header name (default: 'X-Tenant-Id')
|
|
66
84
|
attr_accessor :tenant_id_header_name
|
|
67
85
|
|
|
86
|
+
# The HTTP header name containing the tenant slug
|
|
87
|
+
# @return [String] the tenant slug header name
|
|
88
|
+
attr_accessor :tenant_slug_header_name
|
|
89
|
+
|
|
90
|
+
# The HTTP header name containing the user ID
|
|
91
|
+
# @return [String] the user ID header name
|
|
92
|
+
attr_accessor :user_id_header_name
|
|
93
|
+
|
|
68
94
|
# The regular expression pattern to extract pathname slugs
|
|
69
95
|
# @return [Regexp] the pathname slug pattern (default: /^\/api\/v1\/([^\/]+)\//)
|
|
70
96
|
attr_accessor :pathname_slug_pattern
|
|
@@ -79,47 +105,35 @@ module RackJwtAegis
|
|
|
79
105
|
|
|
80
106
|
# @!group Path Management
|
|
81
107
|
|
|
82
|
-
# Array of
|
|
83
|
-
# @return [Array<String, Regexp>]
|
|
108
|
+
# Array of routes that should skip JWT authentication
|
|
109
|
+
# @return [Array<String, Regexp, Hash>] routes to skip authentication for
|
|
84
110
|
# @example
|
|
85
111
|
# ['/health', '/api/public', /^\/assets/]
|
|
86
|
-
|
|
112
|
+
# [{ path: '/api/v1/sessions', verbs: [:post] }]
|
|
113
|
+
attr_reader :skip_paths, :skip_routes
|
|
87
114
|
|
|
88
115
|
# @!endgroup
|
|
89
116
|
|
|
90
117
|
# @!group Cache Configuration
|
|
91
|
-
|
|
92
|
-
# The primary cache store adapter type
|
|
93
|
-
# @return [Symbol] the cache store type (:memory, :redis, :memcached, :solid_cache)
|
|
94
|
-
attr_accessor :cache_store
|
|
95
|
-
|
|
96
|
-
# Options passed to the cache store adapter
|
|
97
|
-
# @return [Hash] cache store configuration options
|
|
98
|
-
attr_accessor :cache_options
|
|
99
|
-
|
|
100
|
-
# Whether the middleware can write to cache stores
|
|
101
|
-
# @return [Boolean] true if cache writing is enabled
|
|
102
|
-
attr_accessor :cache_write_enabled
|
|
103
|
-
|
|
104
118
|
# The RBAC cache store adapter type (separate from main cache)
|
|
105
119
|
# @return [Symbol] the RBAC cache store type
|
|
106
120
|
attr_accessor :rbac_cache_store
|
|
107
121
|
|
|
108
122
|
# Options for the RBAC cache store
|
|
109
123
|
# @return [Hash] RBAC cache configuration options
|
|
110
|
-
attr_accessor :
|
|
124
|
+
attr_accessor :rbac_cache_store_options
|
|
111
125
|
|
|
112
126
|
# The permission cache store adapter type
|
|
113
127
|
# @return [Symbol] the permission cache store type
|
|
114
|
-
attr_accessor :
|
|
128
|
+
attr_accessor :permissions_cache_store
|
|
115
129
|
|
|
116
130
|
# Options for the permission cache store
|
|
117
131
|
# @return [Hash] permission cache configuration options
|
|
118
|
-
attr_accessor :
|
|
132
|
+
attr_accessor :permissions_cache_store_options
|
|
119
133
|
|
|
120
134
|
# Time-to-live for user permissions cache in seconds
|
|
121
135
|
# @return [Integer] TTL in seconds (default: 1800 - 30 minutes)
|
|
122
|
-
attr_accessor :
|
|
136
|
+
attr_accessor :cached_permissions_ttl
|
|
123
137
|
|
|
124
138
|
# @!endgroup
|
|
125
139
|
|
|
@@ -168,11 +182,13 @@ module RackJwtAegis
|
|
|
168
182
|
# @option options [String] :tenant_id_header_name ('X-Tenant-Id') tenant ID header name
|
|
169
183
|
# @option options [Regexp] :pathname_slug_pattern default pattern for pathname slugs
|
|
170
184
|
# @option options [Hash] :payload_mapping mapping of JWT claim names
|
|
171
|
-
# @option options [Array<String, Regexp>] :skip_paths ([]) paths to skip authentication
|
|
172
|
-
# @option options [
|
|
173
|
-
# @option options [
|
|
174
|
-
# @option options [
|
|
175
|
-
# @option options [
|
|
185
|
+
# @option options [Array<String, Regexp>] :skip_paths ([]) legacy paths to skip authentication
|
|
186
|
+
# @option options [Array<String, Regexp, Hash>] :skip_routes ([]) routes to skip authentication
|
|
187
|
+
# @option options [Symbol] :rbac_cache_store cache adapter type
|
|
188
|
+
# @option options [Hash] :rbac_cache_store_options cache configuration options
|
|
189
|
+
# @option options [Symbol] :permissions_cache_store cache adapter type
|
|
190
|
+
# @option options [Hash] :permissions_cache_store_options cache configuration options
|
|
191
|
+
# @option options [Integer] :cached_permissions_ttl (1800) user permissions cache TTL in seconds
|
|
176
192
|
# @option options [Boolean] :debug_mode (false) enable debug logging
|
|
177
193
|
# @raise [ConfigurationError] if jwt_secret is missing or configuration is invalid
|
|
178
194
|
def initialize(options = {})
|
|
@@ -196,6 +212,12 @@ module RackJwtAegis
|
|
|
196
212
|
config_boolean?(rbac_enabled)
|
|
197
213
|
end
|
|
198
214
|
|
|
215
|
+
# Check if circuit breaker is enabled
|
|
216
|
+
# @return [Boolean] true if circuit breaker is enabled
|
|
217
|
+
def circuit_breaker_enabled?
|
|
218
|
+
config_boolean?(circuit_breaker_enabled)
|
|
219
|
+
end
|
|
220
|
+
|
|
199
221
|
# Check if subdomain validation is enabled
|
|
200
222
|
# @return [Boolean] true if subdomain validation is enabled
|
|
201
223
|
def validate_subdomain?
|
|
@@ -214,33 +236,50 @@ module RackJwtAegis
|
|
|
214
236
|
config_boolean?(validate_tenant_id)
|
|
215
237
|
end
|
|
216
238
|
|
|
239
|
+
# Check if strict authenticated request headers are required
|
|
240
|
+
# @return [Boolean] true if required auth headers are enabled
|
|
241
|
+
def require_authentication_headers?
|
|
242
|
+
config_boolean?(require_authentication_headers)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Check if exp and iat claims are required
|
|
246
|
+
# @return [Boolean] true if expiration claims are required
|
|
247
|
+
def require_expiration_claims?
|
|
248
|
+
config_boolean?(require_expiration_claims)
|
|
249
|
+
end
|
|
250
|
+
|
|
217
251
|
# Check if debug mode is enabled
|
|
218
252
|
# @return [Boolean] true if debug mode is enabled
|
|
219
253
|
def debug_mode?
|
|
220
254
|
config_boolean?(debug_mode)
|
|
221
255
|
end
|
|
222
256
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
257
|
+
def skip_paths=(value)
|
|
258
|
+
@skip_paths = value
|
|
259
|
+
@normalized_skip_routes = nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def skip_routes=(value)
|
|
263
|
+
@skip_routes = value
|
|
264
|
+
@normalized_skip_routes = nil
|
|
227
265
|
end
|
|
228
266
|
|
|
229
267
|
# Check if the given path should skip JWT authentication
|
|
230
268
|
# @param path [String] the request path to check
|
|
231
|
-
# @return [Boolean] true if the path should be skipped
|
|
269
|
+
# @return [Boolean] true if the path should be skipped for any method
|
|
232
270
|
def skip_path?(path)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
271
|
+
skip_request?(path)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Check if the given request should skip JWT authentication
|
|
275
|
+
# @param path [String] the request path to check
|
|
276
|
+
# @param request_method [String, nil] the HTTP method to check
|
|
277
|
+
# @return [Boolean] true if the route should be skipped
|
|
278
|
+
def skip_request?(path, request_method = nil)
|
|
279
|
+
return false if normalized_skip_routes.empty?
|
|
280
|
+
|
|
281
|
+
normalized_skip_routes.any? do |skip_route|
|
|
282
|
+
route_matches?(skip_route, path, request_method)
|
|
244
283
|
end
|
|
245
284
|
end
|
|
246
285
|
|
|
@@ -272,22 +311,39 @@ module RackJwtAegis
|
|
|
272
311
|
@validate_subdomain = false
|
|
273
312
|
@validate_pathname_slug = false
|
|
274
313
|
@validate_tenant_id = false
|
|
275
|
-
@
|
|
314
|
+
@require_authentication_headers = false
|
|
315
|
+
@require_expiration_claims = false
|
|
276
316
|
@tenant_id_header_name = 'X-Tenant-Id'
|
|
317
|
+
@tenant_slug_header_name = 'X-Tenant-Slug'
|
|
318
|
+
@user_id_header_name = 'X-User-Id'
|
|
277
319
|
@pathname_slug_pattern = %r{^/api/v1/([^/]+)/}
|
|
278
320
|
@skip_paths = []
|
|
279
|
-
@
|
|
280
|
-
@user_permissions_ttl = 1800 # 30 minutes default
|
|
281
|
-
@debug_mode = false
|
|
321
|
+
@skip_routes = []
|
|
282
322
|
@payload_mapping = {
|
|
283
323
|
user_id: :user_id,
|
|
284
324
|
tenant_id: :tenant_id,
|
|
325
|
+
tenant_slug: :subdomain,
|
|
285
326
|
subdomain: :subdomain,
|
|
286
327
|
pathname_slugs: :pathname_slugs,
|
|
287
328
|
role_ids: :role_ids,
|
|
288
329
|
}
|
|
289
330
|
@unauthorized_response = { error: 'Authentication required' }
|
|
290
331
|
@forbidden_response = { error: 'Access denied' }
|
|
332
|
+
@rbac_enabled = false
|
|
333
|
+
@circuit_breaker_enabled = false
|
|
334
|
+
@circuit_breaker_failure_threshold = 5
|
|
335
|
+
@circuit_breaker_cooldown_seconds = 30
|
|
336
|
+
@cached_permissions_ttl = 1800 # 30 minutes default
|
|
337
|
+
@rbac_cache_store = if Object.const_defined?(:Rails) && Rails.const_defined?(:Application)
|
|
338
|
+
@debug_mode = Rails.env.development?
|
|
339
|
+
Rails.application.config.cache_store
|
|
340
|
+
else
|
|
341
|
+
@debug_mode = false
|
|
342
|
+
:memory
|
|
343
|
+
end
|
|
344
|
+
@permissions_cache_store = @rbac_cache_store
|
|
345
|
+
@rbac_cache_store_options = {}
|
|
346
|
+
@permissions_cache_store_options = {}
|
|
291
347
|
end
|
|
292
348
|
|
|
293
349
|
def validate!
|
|
@@ -295,6 +351,8 @@ module RackJwtAegis
|
|
|
295
351
|
validate_payload_mapping!
|
|
296
352
|
validate_cache_settings!
|
|
297
353
|
validate_multi_tenant_settings!
|
|
354
|
+
validate_authentication_header_settings!
|
|
355
|
+
validate_circuit_breaker_settings!
|
|
298
356
|
end
|
|
299
357
|
|
|
300
358
|
def validate_jwt_settings!
|
|
@@ -325,27 +383,10 @@ module RackJwtAegis
|
|
|
325
383
|
end
|
|
326
384
|
|
|
327
385
|
def validate_cache_settings!
|
|
328
|
-
return unless rbac_enabled?
|
|
329
|
-
|
|
330
|
-
# Validate cache store configuration
|
|
331
|
-
if cache_store && !cache_write_enabled?
|
|
332
|
-
# Zero trust mode - separate caches required
|
|
333
|
-
if rbac_cache_store.nil?
|
|
334
|
-
raise ConfigurationError, 'rbac_cache_store is required when cache_write_enabled is false'
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
if permission_cache_store.nil?
|
|
338
|
-
@permission_cache_store = :memory # Default fallback
|
|
339
|
-
end
|
|
340
|
-
elsif cache_store.nil? && rbac_cache_store.nil?
|
|
341
|
-
# Both cache stores are missing - at least one is required for RBAC
|
|
342
|
-
raise ConfigurationError, 'cache_store or rbac_cache_store is required when RBAC is enabled'
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
# Set default fallback for permission_cache_store when rbac_cache_store is provided
|
|
346
|
-
return unless !rbac_cache_store.nil? && permission_cache_store.nil?
|
|
386
|
+
return unless rbac_enabled? && (rbac_cache_store.nil? || permissions_cache_store.nil?)
|
|
347
387
|
|
|
348
|
-
|
|
388
|
+
raise ConfigurationError,
|
|
389
|
+
'rbac_cache_store and permissions_cache_store are required when RBAC is enabled'
|
|
349
390
|
end
|
|
350
391
|
|
|
351
392
|
def validate_multi_tenant_settings!
|
|
@@ -367,5 +408,101 @@ module RackJwtAegis
|
|
|
367
408
|
error_msg << 'pathname_slug_pattern is required' if pathname_slug_pattern.to_s.empty?
|
|
368
409
|
raise ConfigurationError, "#{error_msg.join(' and ')} when validate_pathname_slug is true" if error_msg.any?
|
|
369
410
|
end
|
|
411
|
+
|
|
412
|
+
def normalized_skip_routes
|
|
413
|
+
@normalized_skip_routes ||= normalize_skip_routes
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def normalize_skip_routes
|
|
417
|
+
normalize_skip_route_entries(skip_paths) + normalize_skip_route_entries(skip_routes)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def normalize_skip_route_entries(entries)
|
|
421
|
+
Array(entries).each_with_object([]) do |entry, normalized|
|
|
422
|
+
case entry
|
|
423
|
+
when String, Regexp
|
|
424
|
+
normalized << { path: entry, methods: nil }
|
|
425
|
+
when Hash
|
|
426
|
+
path = entry.key?(:path) ? entry[:path] : entry['path']
|
|
427
|
+
next if path.nil?
|
|
428
|
+
|
|
429
|
+
verbs = if entry.key?(:verbs)
|
|
430
|
+
entry[:verbs]
|
|
431
|
+
elsif entry.key?('verbs')
|
|
432
|
+
entry['verbs']
|
|
433
|
+
elsif entry.key?(:verb)
|
|
434
|
+
entry[:verb]
|
|
435
|
+
elsif entry.key?('verb')
|
|
436
|
+
entry['verb']
|
|
437
|
+
elsif entry.key?(:methods)
|
|
438
|
+
entry[:methods]
|
|
439
|
+
elsif entry.key?('methods')
|
|
440
|
+
entry['methods']
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
normalized << { path: path, verbs: normalize_skip_verbs(verbs) }
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def normalize_skip_verbs(verbs)
|
|
449
|
+
return nil if verbs.nil?
|
|
450
|
+
return nil if verbs.respond_to?(:empty?) && verbs.empty?
|
|
451
|
+
return nil if verbs.is_a?(String) && verbs.strip.empty?
|
|
452
|
+
|
|
453
|
+
normalized = Array(verbs).flatten.compact.map do |verb|
|
|
454
|
+
verb.to_s.strip.upcase
|
|
455
|
+
end.reject(&:empty?)
|
|
456
|
+
|
|
457
|
+
normalized.empty? ? nil : normalized.uniq
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def route_matches?(skip_route, path, request_method)
|
|
461
|
+
path_matches = case skip_route[:path]
|
|
462
|
+
when String
|
|
463
|
+
skip_route[:path] == path
|
|
464
|
+
when Regexp
|
|
465
|
+
skip_route[:path].match?(path)
|
|
466
|
+
else
|
|
467
|
+
false
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
return false unless path_matches
|
|
471
|
+
|
|
472
|
+
verbs = skip_route[:verbs]
|
|
473
|
+
return true if verbs.nil?
|
|
474
|
+
|
|
475
|
+
return false if request_method.nil?
|
|
476
|
+
|
|
477
|
+
verbs.include?(request_method.to_s.upcase)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def validate_authentication_header_settings!
|
|
481
|
+
return unless require_authentication_headers?
|
|
482
|
+
|
|
483
|
+
error_msg = []
|
|
484
|
+
error_msg << 'payload_mapping must include :user_id' unless payload_mapping.key?(:user_id)
|
|
485
|
+
error_msg << 'payload_mapping must include :tenant_id' unless payload_mapping.key?(:tenant_id)
|
|
486
|
+
error_msg << 'payload_mapping must include :tenant_slug' unless payload_mapping.key?(:tenant_slug)
|
|
487
|
+
error_msg << 'tenant_id_header_name is required' if tenant_id_header_name.to_s.strip.empty?
|
|
488
|
+
error_msg << 'tenant_slug_header_name is required' if tenant_slug_header_name.to_s.strip.empty?
|
|
489
|
+
error_msg << 'user_id_header_name is required' if user_id_header_name.to_s.strip.empty?
|
|
490
|
+
return unless error_msg.any?
|
|
491
|
+
|
|
492
|
+
raise ConfigurationError,
|
|
493
|
+
"#{error_msg.join(' and ')} when require_authentication_headers is true"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def validate_circuit_breaker_settings!
|
|
497
|
+
return unless circuit_breaker_enabled?
|
|
498
|
+
|
|
499
|
+
if circuit_breaker_failure_threshold.to_i <= 0
|
|
500
|
+
raise ConfigurationError, 'circuit_breaker_failure_threshold must be greater than 0'
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
return if circuit_breaker_cooldown_seconds.to_i >= 0
|
|
504
|
+
|
|
505
|
+
raise ConfigurationError, 'circuit_breaker_cooldown_seconds must be greater than or equal to 0'
|
|
506
|
+
end
|
|
370
507
|
end
|
|
371
508
|
end
|
|
@@ -101,7 +101,10 @@ module RackJwtAegis
|
|
|
101
101
|
required_claims << @config.payload_key(:subdomain) if @config.validate_subdomain?
|
|
102
102
|
required_claims << @config.payload_key(:tenant_id) if @config.validate_tenant_id?
|
|
103
103
|
required_claims << @config.payload_key(:pathname_slugs) if @config.validate_pathname_slug?
|
|
104
|
+
required_claims << @config.payload_key(:tenant_id) if @config.require_authentication_headers?
|
|
105
|
+
required_claims << @config.payload_key(:tenant_slug) if @config.require_authentication_headers?
|
|
104
106
|
required_claims << @config.payload_key(:role_ids) if @config.rbac_enabled?
|
|
107
|
+
required_claims += [:exp, :iat] if @config.require_expiration_claims?
|
|
105
108
|
|
|
106
109
|
missing_claims = required_claims.select { |claim| payload[claim.to_s].to_s.empty? }
|
|
107
110
|
return if missing_claims.empty?
|
|
@@ -128,6 +131,25 @@ module RackJwtAegis
|
|
|
128
131
|
end
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
if @config.require_authentication_headers?
|
|
135
|
+
tenant_id = payload[@config.payload_key(:tenant_id).to_s]
|
|
136
|
+
if tenant_id.to_s.empty? || (!tenant_id.is_a?(Numeric) && !tenant_id.is_a?(String))
|
|
137
|
+
raise AuthenticationError, 'Invalid tenant_id format in JWT payload'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
tenant_slug = payload[@config.payload_key(:tenant_slug).to_s]
|
|
141
|
+
if tenant_slug.to_s.empty? || !tenant_slug.is_a?(String)
|
|
142
|
+
raise AuthenticationError, 'Invalid tenant_slug format in JWT payload'
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
if @config.require_expiration_claims?
|
|
147
|
+
['exp', 'iat'].each do |claim|
|
|
148
|
+
value = payload[claim]
|
|
149
|
+
raise AuthenticationError, "Invalid #{claim} format in JWT payload" unless value.is_a?(Numeric)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
131
153
|
# Company group domain should be string (if present)
|
|
132
154
|
if @config.validate_subdomain?
|
|
133
155
|
subdomain = payload[@config.payload_key(:subdomain).to_s]
|
|
@@ -24,7 +24,7 @@ module RackJwtAegis
|
|
|
24
24
|
# validate_subdomain: true,
|
|
25
25
|
# rbac_enabled: true,
|
|
26
26
|
# cache_store: :redis,
|
|
27
|
-
#
|
|
27
|
+
# skip_routes: [{ path: '/health' }, { path: '/api/v1/sessions', verbs: [:post] }]
|
|
28
28
|
# }
|
|
29
29
|
class Middleware
|
|
30
30
|
include DebugLogger
|
|
@@ -43,6 +43,7 @@ module RackJwtAegis
|
|
|
43
43
|
@rbac_manager = RbacManager.new(@config) if @config.rbac_enabled?
|
|
44
44
|
@response_builder = ResponseBuilder.new(@config)
|
|
45
45
|
@request_context = RequestContext.new(@config)
|
|
46
|
+
@circuit_breaker = build_circuit_breaker
|
|
46
47
|
|
|
47
48
|
debug_log("Middleware initialized with features: #{enabled_features}")
|
|
48
49
|
end
|
|
@@ -58,10 +59,15 @@ module RackJwtAegis
|
|
|
58
59
|
|
|
59
60
|
debug_log("Processing request: #{request.request_method} #{request.path}")
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
unless circuit_allows_request?
|
|
63
|
+
debug_log('Circuit breaker open; failing fast')
|
|
64
|
+
return @response_builder.error_response('Circuit breaker open', 503)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Step 1: Check if route should be skipped
|
|
68
|
+
if @config.skip_request?(request.path, request.request_method)
|
|
69
|
+
debug_log("Skipping authentication for route: #{request.request_method} #{request.path}")
|
|
70
|
+
return call_app(env)
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
begin
|
|
@@ -101,7 +107,7 @@ module RackJwtAegis
|
|
|
101
107
|
debug_log('Request context set successfully')
|
|
102
108
|
|
|
103
109
|
# Continue to application
|
|
104
|
-
|
|
110
|
+
call_app(env)
|
|
105
111
|
rescue AuthenticationError => e
|
|
106
112
|
debug_log("Authentication failed: #{e.message}")
|
|
107
113
|
@response_builder.unauthorized_response(e.message)
|
|
@@ -109,6 +115,7 @@ module RackJwtAegis
|
|
|
109
115
|
debug_log("Authorization failed: #{e.message}")
|
|
110
116
|
@response_builder.forbidden_response(e.message)
|
|
111
117
|
rescue StandardError => e
|
|
118
|
+
record_circuit_failure
|
|
112
119
|
debug_log("Unexpected error: #{e.message}")
|
|
113
120
|
if @config.debug_mode?
|
|
114
121
|
@response_builder.error_response("Internal error: #{e.message}", 500)
|
|
@@ -144,7 +151,31 @@ module RackJwtAegis
|
|
|
144
151
|
#
|
|
145
152
|
# @return [Boolean] true if subdomain or pathname slug validation is enabled
|
|
146
153
|
def multi_tenant_enabled?
|
|
147
|
-
@config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug?
|
|
154
|
+
@config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug? ||
|
|
155
|
+
@config.require_authentication_headers?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_circuit_breaker
|
|
159
|
+
return nil unless @config.circuit_breaker_enabled?
|
|
160
|
+
|
|
161
|
+
CircuitBreaker.new(
|
|
162
|
+
failure_threshold: @config.circuit_breaker_failure_threshold,
|
|
163
|
+
cooldown_seconds: @config.circuit_breaker_cooldown_seconds,
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def circuit_allows_request?
|
|
168
|
+
@circuit_breaker.nil? || @circuit_breaker.allow_request?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def call_app(env)
|
|
172
|
+
response = @app.call(env)
|
|
173
|
+
@circuit_breaker&.record_success
|
|
174
|
+
response
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def record_circuit_failure
|
|
178
|
+
@circuit_breaker&.record_failure
|
|
148
179
|
end
|
|
149
180
|
|
|
150
181
|
# Generate a string describing enabled features for logging
|
|
@@ -155,6 +186,8 @@ module RackJwtAegis
|
|
|
155
186
|
features << 'TenantId' if @config.validate_tenant_id?
|
|
156
187
|
features << 'Subdomain' if @config.validate_subdomain?
|
|
157
188
|
features << 'PathnameSlug' if @config.validate_pathname_slug?
|
|
189
|
+
features << 'StrictHeaders' if @config.require_authentication_headers?
|
|
190
|
+
features << 'CircuitBreaker' if @config.circuit_breaker_enabled?
|
|
158
191
|
features << 'RBAC' if @config.rbac_enabled?
|
|
159
192
|
features.join(', ')
|
|
160
193
|
end
|
|
@@ -33,6 +33,7 @@ module RackJwtAegis
|
|
|
33
33
|
# @param payload [Hash] the JWT payload containing tenant information
|
|
34
34
|
# @raise [AuthorizationError] if tenant validation fails
|
|
35
35
|
def validate(request, payload)
|
|
36
|
+
validate_authentication_headers(request, payload)
|
|
36
37
|
validate_subdomain(request, payload)
|
|
37
38
|
validate_pathname_slug(request, payload)
|
|
38
39
|
validate_tenant_id_header(request, payload)
|
|
@@ -40,6 +41,34 @@ module RackJwtAegis
|
|
|
40
41
|
|
|
41
42
|
private
|
|
42
43
|
|
|
44
|
+
def validate_authentication_headers(request, payload)
|
|
45
|
+
return unless @config.require_authentication_headers?
|
|
46
|
+
|
|
47
|
+
validate_header_claim_match(
|
|
48
|
+
request,
|
|
49
|
+
payload,
|
|
50
|
+
header_name: @config.tenant_id_header_name,
|
|
51
|
+
claim_name: @config.payload_key(:tenant_id),
|
|
52
|
+
label: 'tenant id',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
validate_header_claim_match(
|
|
56
|
+
request,
|
|
57
|
+
payload,
|
|
58
|
+
header_name: @config.tenant_slug_header_name,
|
|
59
|
+
claim_name: @config.payload_key(:tenant_slug),
|
|
60
|
+
label: 'tenant slug',
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
validate_header_claim_match(
|
|
64
|
+
request,
|
|
65
|
+
payload,
|
|
66
|
+
header_name: @config.user_id_header_name,
|
|
67
|
+
claim_name: @config.payload_key(:user_id),
|
|
68
|
+
label: 'user id',
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
43
72
|
# Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain
|
|
44
73
|
def validate_subdomain(request, payload)
|
|
45
74
|
return unless @config.validate_subdomain?
|
|
@@ -75,14 +104,15 @@ module RackJwtAegis
|
|
|
75
104
|
accessible_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
|
|
76
105
|
|
|
77
106
|
if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
|
|
78
|
-
raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for
|
|
107
|
+
raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for pathname slug access validation'
|
|
79
108
|
end
|
|
80
109
|
|
|
81
110
|
# Check if requested company slug is in user's accessible list
|
|
82
111
|
return if accessible_slugs.map(&:downcase).include?(pathname_slug)
|
|
83
112
|
|
|
113
|
+
# TODO: make this error configurable as well
|
|
84
114
|
raise AuthorizationError,
|
|
85
|
-
"
|
|
115
|
+
"Pathname slug access denied: '#{pathname_slug}' not in accessible pathname slugs #{accessible_slugs}"
|
|
86
116
|
end
|
|
87
117
|
|
|
88
118
|
# Company Group header validation (additional security layer)
|
|
@@ -101,6 +131,23 @@ module RackJwtAegis
|
|
|
101
131
|
"Tenant id header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
|
|
102
132
|
end
|
|
103
133
|
|
|
134
|
+
def validate_header_claim_match(request, payload, header_name:, claim_name:, label:)
|
|
135
|
+
header_value = header_value(request, header_name)
|
|
136
|
+
raise AuthorizationError, "Required authentication header missing: #{header_name}" if header_value.empty?
|
|
137
|
+
|
|
138
|
+
jwt_claim = payload[claim_name.to_s].to_s.strip.downcase
|
|
139
|
+
raise AuthorizationError, "JWT payload missing #{claim_name} for #{label} header validation" if jwt_claim.empty?
|
|
140
|
+
|
|
141
|
+
return if header_value.eql?(jwt_claim)
|
|
142
|
+
|
|
143
|
+
raise AuthorizationError,
|
|
144
|
+
"#{label.capitalize} header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def header_value(request, header_name)
|
|
148
|
+
request.get_header("HTTP_#{header_name.upcase.tr('-', '_')}").to_s.strip.downcase
|
|
149
|
+
end
|
|
150
|
+
|
|
104
151
|
def extract_subdomain(host)
|
|
105
152
|
return nil if host.nil? || host.empty?
|
|
106
153
|
|
|
@@ -17,8 +17,6 @@ module RackJwtAegis
|
|
|
17
17
|
class RbacManager
|
|
18
18
|
include DebugLogger
|
|
19
19
|
|
|
20
|
-
CACHE_TTL = 300 # 5 minutes default cache TTL
|
|
21
|
-
|
|
22
20
|
# Initialize the RBAC manager
|
|
23
21
|
#
|
|
24
22
|
# @param config [Configuration] the configuration instance
|
|
@@ -38,21 +36,12 @@ module RackJwtAegis
|
|
|
38
36
|
|
|
39
37
|
# Build permission key
|
|
40
38
|
permission_key = build_permission_key(user_id, request)
|
|
41
|
-
|
|
42
|
-
# Check cached permission first (if middleware can write to cache)
|
|
43
|
-
if @permission_cache && @config.cache_write_enabled?
|
|
44
|
-
cached_permission = check_cached_permission(permission_key)
|
|
45
|
-
return if cached_permission == true
|
|
46
|
-
|
|
47
|
-
raise AuthorizationError, 'Access denied - cached permission' if cached_permission == false
|
|
48
|
-
end
|
|
39
|
+
return if check_cached_permission(permission_key) == true
|
|
49
40
|
|
|
50
41
|
# Permission not cached or cache miss - check RBAC store
|
|
51
42
|
has_permission = check_rbac_permission(user_id, request)
|
|
52
|
-
|
|
53
43
|
# Cache the result if middleware has write access
|
|
54
44
|
cache_permission_result(permission_key, has_permission)
|
|
55
|
-
|
|
56
45
|
return if has_permission
|
|
57
46
|
|
|
58
47
|
raise AuthorizationError, 'Access denied - insufficient permissions'
|
|
@@ -61,25 +50,15 @@ module RackJwtAegis
|
|
|
61
50
|
private
|
|
62
51
|
|
|
63
52
|
def setup_cache_adapters
|
|
64
|
-
|
|
65
|
-
# Shared cache mode - both RBAC and permission cache use same store
|
|
66
|
-
@rbac_cache = CacheAdapter.build(@config.cache_store, @config.cache_options || {})
|
|
67
|
-
@permission_cache = @rbac_cache
|
|
68
|
-
else
|
|
69
|
-
# Separate cache mode - different stores for RBAC and permissions
|
|
70
|
-
if @config.rbac_cache_store
|
|
71
|
-
@rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_options || {})
|
|
72
|
-
end
|
|
53
|
+
return unless @config.rbac_enabled
|
|
73
54
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
begin
|
|
56
|
+
@rbac_cache = CacheAdapter.build(@config.rbac_cache_store, @config.rbac_cache_store_options || {})
|
|
57
|
+
@permissions_cache = CacheAdapter.build(@config.permissions_cache_store,
|
|
58
|
+
@config.permissions_cache_store_options || {})
|
|
59
|
+
rescue ConfigurationError
|
|
60
|
+
raise ConfigurationError, 'RBAC cache store not configured'
|
|
77
61
|
end
|
|
78
|
-
|
|
79
|
-
# Ensure we have at least RBAC cache for permission lookups
|
|
80
|
-
return if @rbac_cache
|
|
81
|
-
|
|
82
|
-
raise ConfigurationError, 'RBAC cache store not configured'
|
|
83
62
|
end
|
|
84
63
|
|
|
85
64
|
def build_permission_key(user_id, request)
|
|
@@ -87,11 +66,11 @@ module RackJwtAegis
|
|
|
87
66
|
end
|
|
88
67
|
|
|
89
68
|
def check_cached_permission(permission_key)
|
|
90
|
-
return nil unless @
|
|
69
|
+
return nil unless @permissions_cache
|
|
91
70
|
|
|
92
71
|
begin
|
|
93
72
|
# Get the cached user permissions
|
|
94
|
-
user_permissions = @
|
|
73
|
+
user_permissions = @permissions_cache.read('user_permissions')
|
|
95
74
|
return nil if user_permissions.nil? || !user_permissions.is_a?(Hash)
|
|
96
75
|
|
|
97
76
|
# First check: If RBAC permissions were updated recently, nuke ALL cached permissions
|
|
@@ -100,7 +79,7 @@ module RackJwtAegis
|
|
|
100
79
|
rbac_update_age = Time.now.to_i - rbac_last_update
|
|
101
80
|
|
|
102
81
|
# If RBAC was updated within the TTL period, all cached permissions are invalid
|
|
103
|
-
if rbac_update_age <= @config.
|
|
82
|
+
if rbac_update_age <= @config.cached_permissions_ttl
|
|
104
83
|
nuke_user_permissions_cache("RBAC permissions updated recently (#{rbac_update_age}s ago, within TTL)")
|
|
105
84
|
return nil
|
|
106
85
|
end
|
|
@@ -113,10 +92,10 @@ module RackJwtAegis
|
|
|
113
92
|
permission_age = Time.now.to_i - cached_timestamp
|
|
114
93
|
|
|
115
94
|
# Second check: TTL expiration
|
|
116
|
-
if permission_age > @config.
|
|
95
|
+
if permission_age > @config.cached_permissions_ttl
|
|
117
96
|
# This specific permission expired due to TTL
|
|
118
97
|
remove_stale_permission(permission_key,
|
|
119
|
-
"TTL expired (#{permission_age}s > #{@config.
|
|
98
|
+
"TTL expired (#{permission_age}s > #{@config.cached_permissions_ttl}s)")
|
|
120
99
|
return nil
|
|
121
100
|
end
|
|
122
101
|
|
|
@@ -148,20 +127,20 @@ module RackJwtAegis
|
|
|
148
127
|
end
|
|
149
128
|
|
|
150
129
|
def cache_permission_result(permission_key, has_permission)
|
|
151
|
-
return unless @
|
|
130
|
+
return unless @permissions_cache
|
|
152
131
|
return unless has_permission # Only cache positive permissions
|
|
153
132
|
|
|
154
133
|
begin
|
|
155
134
|
current_time = Time.now.to_i
|
|
156
135
|
|
|
157
136
|
# Get existing user permissions cache or create new one
|
|
158
|
-
user_permissions = @
|
|
137
|
+
user_permissions = @permissions_cache.read('user_permissions') || {}
|
|
159
138
|
|
|
160
139
|
# Store permission with new format: {"user_id:full_url:method" => timestamp}
|
|
161
140
|
user_permissions[permission_key] = current_time
|
|
162
141
|
|
|
163
142
|
# Write back to cache
|
|
164
|
-
@
|
|
143
|
+
@permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
|
|
165
144
|
|
|
166
145
|
debug_log("Cached permission: #{permission_key} => #{current_time}")
|
|
167
146
|
rescue CacheError => e
|
|
@@ -191,9 +170,7 @@ module RackJwtAegis
|
|
|
191
170
|
next unless matched_permission
|
|
192
171
|
|
|
193
172
|
# Cache this specific permission match for faster future lookups
|
|
194
|
-
|
|
195
|
-
cache_permission_match(user_id, request, role_id, matched_permission)
|
|
196
|
-
end
|
|
173
|
+
cache_matched_permission(user_id, request)
|
|
197
174
|
return true
|
|
198
175
|
end
|
|
199
176
|
|
|
@@ -219,10 +196,10 @@ module RackJwtAegis
|
|
|
219
196
|
|
|
220
197
|
# Remove a specific stale permission
|
|
221
198
|
def remove_stale_permission(permission_key, reason)
|
|
222
|
-
return unless @
|
|
199
|
+
return unless @permissions_cache
|
|
223
200
|
|
|
224
201
|
begin
|
|
225
|
-
user_permissions = @
|
|
202
|
+
user_permissions = @permissions_cache.read('user_permissions')
|
|
226
203
|
return unless user_permissions.is_a?(Hash)
|
|
227
204
|
|
|
228
205
|
# Remove the specific permission key
|
|
@@ -230,11 +207,11 @@ module RackJwtAegis
|
|
|
230
207
|
|
|
231
208
|
# If no permissions remain, remove the entire cache
|
|
232
209
|
if user_permissions.empty?
|
|
233
|
-
@
|
|
210
|
+
@permissions_cache.delete('user_permissions')
|
|
234
211
|
debug_log("Removed last permission, cleared entire cache: #{reason}")
|
|
235
212
|
else
|
|
236
213
|
# Update the cache with the modified permissions
|
|
237
|
-
@
|
|
214
|
+
@permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
|
|
238
215
|
debug_log("Removed stale permission #{permission_key}: #{reason}")
|
|
239
216
|
end
|
|
240
217
|
rescue CacheError => e
|
|
@@ -244,10 +221,10 @@ module RackJwtAegis
|
|
|
244
221
|
|
|
245
222
|
# Nuke (delete) the entire user permissions cache
|
|
246
223
|
def nuke_user_permissions_cache(reason)
|
|
247
|
-
return unless @
|
|
224
|
+
return unless @permissions_cache
|
|
248
225
|
|
|
249
226
|
begin
|
|
250
|
-
@
|
|
227
|
+
@permissions_cache.delete('user_permissions')
|
|
251
228
|
debug_log("Nuked user permissions cache: #{reason}")
|
|
252
229
|
rescue CacheError => e
|
|
253
230
|
debug_log("RbacManager cache nuke error: #{e.message}", :warn)
|
|
@@ -290,27 +267,18 @@ module RackJwtAegis
|
|
|
290
267
|
|
|
291
268
|
# Cache the specific permission match for faster future lookups
|
|
292
269
|
# Format: {"user_id:full_url:method" => timestamp}
|
|
293
|
-
def
|
|
294
|
-
return unless @
|
|
270
|
+
def cache_matched_permission(user_id, request)
|
|
271
|
+
return unless @permissions_cache
|
|
295
272
|
|
|
296
273
|
begin
|
|
297
274
|
current_time = Time.now.to_i
|
|
298
|
-
|
|
299
|
-
# Build the permission key in new format
|
|
300
|
-
host = request.host || 'localhost'
|
|
301
|
-
full_url = "#{host}#{request.path}"
|
|
302
|
-
method = request.request_method.downcase
|
|
303
|
-
permission_key = "#{user_id}:#{full_url}:#{method}"
|
|
304
|
-
|
|
275
|
+
permission_key = "#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
|
|
305
276
|
# Get existing user permissions cache or create new one
|
|
306
|
-
user_permissions = @
|
|
307
|
-
|
|
277
|
+
user_permissions = @permissions_cache.read('user_permissions') || {}
|
|
308
278
|
# Store permission with new format
|
|
309
279
|
user_permissions[permission_key] = current_time
|
|
310
|
-
|
|
311
280
|
# Write back to cache
|
|
312
|
-
@
|
|
313
|
-
|
|
281
|
+
@permissions_cache.write('user_permissions', user_permissions, expires_in: @config.cached_permissions_ttl)
|
|
314
282
|
debug_log("Cached user permission: #{permission_key} => #{current_time}")
|
|
315
283
|
rescue CacheError => e
|
|
316
284
|
# Log cache error but don't fail the request
|
|
@@ -24,6 +24,7 @@ module RackJwtAegis
|
|
|
24
24
|
USER_ID_KEY = 'rack_jwt_aegis.user_id'
|
|
25
25
|
TENANT_ID_KEY = 'rack_jwt_aegis.tenant_id'
|
|
26
26
|
SUBDOMAIN_KEY = 'rack_jwt_aegis.subdomain'
|
|
27
|
+
TENANT_SLUG_KEY = SUBDOMAIN_KEY
|
|
27
28
|
PATHNAME_SLUGS_KEY = 'rack_jwt_aegis.pathname_slugs'
|
|
28
29
|
AUTHENTICATED_KEY = 'rack_jwt_aegis.authenticated'
|
|
29
30
|
|
|
@@ -84,6 +85,10 @@ module RackJwtAegis
|
|
|
84
85
|
env[TENANT_ID_KEY]
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
def self.tenant_slug(env)
|
|
89
|
+
env[TENANT_SLUG_KEY]
|
|
90
|
+
end
|
|
91
|
+
|
|
87
92
|
def self.subdomain(env)
|
|
88
93
|
env[SUBDOMAIN_KEY]
|
|
89
94
|
end
|
|
@@ -100,6 +105,10 @@ module RackJwtAegis
|
|
|
100
105
|
tenant_id(request.env)
|
|
101
106
|
end
|
|
102
107
|
|
|
108
|
+
def self.current_tenant_slug(request)
|
|
109
|
+
tenant_slug(request.env)
|
|
110
|
+
end
|
|
111
|
+
|
|
103
112
|
def self.has_pathname_slug_access?(env, pathname_slug)
|
|
104
113
|
pathname_slugs(env).include?(pathname_slug)
|
|
105
114
|
end
|
|
@@ -112,7 +121,11 @@ module RackJwtAegis
|
|
|
112
121
|
|
|
113
122
|
def set_tenant_context(env, payload)
|
|
114
123
|
# Set multi-tenant information
|
|
115
|
-
|
|
124
|
+
if @config.validate_tenant_id? || @config.require_authentication_headers?
|
|
125
|
+
env[TENANT_ID_KEY] =
|
|
126
|
+
payload[@config.payload_key(:tenant_id).to_s]
|
|
127
|
+
end
|
|
128
|
+
env[SUBDOMAIN_KEY] = payload[@config.payload_key(:tenant_slug).to_s] if @config.require_authentication_headers?
|
|
116
129
|
env[SUBDOMAIN_KEY] = payload[@config.payload_key(:subdomain).to_s] if @config.validate_subdomain?
|
|
117
130
|
return unless @config.validate_pathname_slug? || @config.payload_mapping.key?(:pathname_slugs)
|
|
118
131
|
|
data/lib/rack_jwt_aegis.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative 'rack_jwt_aegis/version'
|
|
4
4
|
require_relative 'rack_jwt_aegis/configuration'
|
|
5
5
|
require_relative 'rack_jwt_aegis/debug_logger'
|
|
6
|
+
require_relative 'rack_jwt_aegis/circuit_breaker'
|
|
6
7
|
require_relative 'rack_jwt_aegis/middleware'
|
|
7
8
|
require_relative 'rack_jwt_aegis/jwt_validator'
|
|
8
9
|
require_relative 'rack_jwt_aegis/multi_tenant_validator'
|
|
@@ -23,7 +24,7 @@ require_relative 'rack_jwt_aegis/response_builder'
|
|
|
23
24
|
# - RBAC with flexible permission caching
|
|
24
25
|
# - Multiple cache adapter support (Memory, Redis, Memcached, SolidCache)
|
|
25
26
|
# - Request context management
|
|
26
|
-
# - Configurable skip
|
|
27
|
+
# - Configurable skip routes and custom validators
|
|
27
28
|
#
|
|
28
29
|
# @example Basic usage
|
|
29
30
|
# use RackJwtAegis::Middleware, jwt_secret: ENV['JWT_SECRET']
|
|
@@ -34,8 +35,6 @@ require_relative 'rack_jwt_aegis/response_builder'
|
|
|
34
35
|
# validate_subdomain: true,
|
|
35
36
|
# validate_pathname_slug: true,
|
|
36
37
|
# rbac_enabled: true,
|
|
37
|
-
# cache_store: :redis,
|
|
38
|
-
# cache_write_enabled: true
|
|
39
38
|
# }
|
|
40
39
|
module RackJwtAegis
|
|
41
40
|
# Base error class for all RackJwtAegis exceptions
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rack_jwt_aegis
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ken C. Demanawa
|
|
@@ -43,6 +43,26 @@ dependencies:
|
|
|
43
43
|
- - ">="
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '3.2'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: ratomic
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 0.4.1
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0.5'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 0.4.1
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0.5'
|
|
46
66
|
description: |-
|
|
47
67
|
JWT authentication and authorization midleware with multi-tenant suport,\
|
|
48
68
|
company validation, and subdomain isolation.
|
|
@@ -54,6 +74,7 @@ extensions: []
|
|
|
54
74
|
extra_rdoc_files: []
|
|
55
75
|
files:
|
|
56
76
|
- ".rubocop.yml"
|
|
77
|
+
- ".ruby-version"
|
|
57
78
|
- ".yardopts"
|
|
58
79
|
- CHANGELOG.md
|
|
59
80
|
- CODE_OF_CONDUCT.md
|
|
@@ -66,6 +87,7 @@ files:
|
|
|
66
87
|
- exe/rack_jwt_aegis
|
|
67
88
|
- lib/rack_jwt_aegis.rb
|
|
68
89
|
- lib/rack_jwt_aegis/cache_adapter.rb
|
|
90
|
+
- lib/rack_jwt_aegis/circuit_breaker.rb
|
|
69
91
|
- lib/rack_jwt_aegis/configuration.rb
|
|
70
92
|
- lib/rack_jwt_aegis/debug_logger.rb
|
|
71
93
|
- lib/rack_jwt_aegis/jwt_validator.rb
|