rack_jwt_aegis 0.0.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 +7 -0
- data/.rubocop.yml +160 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +215 -0
- data/Rakefile +12 -0
- data/examples/basic_usage.rb +85 -0
- data/lib/rack_jwt_aegis/cache_adapter.rb +264 -0
- data/lib/rack_jwt_aegis/configuration.rb +173 -0
- data/lib/rack_jwt_aegis/jwt_validator.rb +113 -0
- data/lib/rack_jwt_aegis/middleware.rb +116 -0
- data/lib/rack_jwt_aegis/multi_tenant_validator.rb +129 -0
- data/lib/rack_jwt_aegis/rbac_manager.rb +197 -0
- data/lib/rack_jwt_aegis/request_context.rb +100 -0
- data/lib/rack_jwt_aegis/response_builder.rb +59 -0
- data/lib/rack_jwt_aegis/version.rb +5 -0
- data/lib/rack_jwt_aegis.rb +19 -0
- data/sig/rack_jwt_bastion.rbs +4 -0
- metadata +93 -0
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RackJwtAegis
|
4
|
+
class CacheAdapter
|
5
|
+
def self.build(store_type, options = {})
|
6
|
+
case store_type
|
7
|
+
when :memory
|
8
|
+
MemoryAdapter.new(options)
|
9
|
+
when :redis
|
10
|
+
RedisAdapter.new(options)
|
11
|
+
when :memcached
|
12
|
+
MemcachedAdapter.new(options)
|
13
|
+
when :solid_cache
|
14
|
+
SolidCacheAdapter.new(options)
|
15
|
+
else
|
16
|
+
raise ConfigurationError, "Unsupported cache store: #{store_type}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(options = {})
|
21
|
+
@options = options
|
22
|
+
end
|
23
|
+
|
24
|
+
# Abstract methods - must be implemented by subclasses
|
25
|
+
def read(key)
|
26
|
+
raise NotImplementedError, 'Subclass must implement #read'
|
27
|
+
end
|
28
|
+
|
29
|
+
def write(key, value, expires_in: nil)
|
30
|
+
raise NotImplementedError, 'Subclass must implement #write'
|
31
|
+
end
|
32
|
+
|
33
|
+
def delete(key)
|
34
|
+
raise NotImplementedError, 'Subclass must implement #delete'
|
35
|
+
end
|
36
|
+
|
37
|
+
def exist?(key)
|
38
|
+
!read(key).nil?
|
39
|
+
end
|
40
|
+
|
41
|
+
def clear
|
42
|
+
raise NotImplementedError, 'Subclass must implement #clear'
|
43
|
+
end
|
44
|
+
|
45
|
+
# Helper methods
|
46
|
+
protected
|
47
|
+
|
48
|
+
def serialize_value(value)
|
49
|
+
case value
|
50
|
+
when String, Numeric, TrueClass, FalseClass, NilClass
|
51
|
+
value
|
52
|
+
else
|
53
|
+
JSON.generate(value)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def deserialize_value(value, _original_type = nil)
|
58
|
+
return value if value.nil?
|
59
|
+
return value unless value.is_a?(String)
|
60
|
+
|
61
|
+
# Try to parse as JSON, fallback to string
|
62
|
+
begin
|
63
|
+
JSON.parse(value)
|
64
|
+
rescue JSON::ParserError
|
65
|
+
value
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Memory-based cache adapter (for development/testing)
|
71
|
+
class MemoryAdapter < CacheAdapter
|
72
|
+
def initialize(options = {})
|
73
|
+
super
|
74
|
+
@store = {}
|
75
|
+
@expires = {}
|
76
|
+
@mutex = Mutex.new
|
77
|
+
end
|
78
|
+
|
79
|
+
def read(key)
|
80
|
+
@mutex.synchronize do
|
81
|
+
cleanup_expired
|
82
|
+
value = @store[key.to_s]
|
83
|
+
deserialize_value(value)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def write(key, value, expires_in: nil)
|
88
|
+
@mutex.synchronize do
|
89
|
+
key_str = key.to_s
|
90
|
+
@store[key_str] = serialize_value(value)
|
91
|
+
|
92
|
+
if expires_in
|
93
|
+
@expires[key_str] = Time.now + expires_in
|
94
|
+
else
|
95
|
+
@expires.delete(key_str)
|
96
|
+
end
|
97
|
+
|
98
|
+
true
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def delete(key)
|
103
|
+
@mutex.synchronize do
|
104
|
+
key_str = key.to_s
|
105
|
+
@store.delete(key_str)
|
106
|
+
@expires.delete(key_str)
|
107
|
+
true
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def clear
|
112
|
+
@mutex.synchronize do
|
113
|
+
@store.clear
|
114
|
+
@expires.clear
|
115
|
+
true
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def cleanup_expired
|
122
|
+
return unless @expires.any?
|
123
|
+
|
124
|
+
now = Time.now
|
125
|
+
expired_keys = @expires.select { |_, expiry| expiry < now }.keys
|
126
|
+
|
127
|
+
expired_keys.each do |key|
|
128
|
+
@store.delete(key)
|
129
|
+
@expires.delete(key)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Redis cache adapter
|
135
|
+
class RedisAdapter < CacheAdapter
|
136
|
+
def initialize(options = {})
|
137
|
+
super
|
138
|
+
require 'redis' unless defined?(Redis)
|
139
|
+
|
140
|
+
@redis = options[:redis_instance] || Redis.new(options)
|
141
|
+
rescue LoadError
|
142
|
+
raise CacheError, "Redis gem not found. Add 'gem \"redis\"' to your Gemfile."
|
143
|
+
end
|
144
|
+
|
145
|
+
def read(key)
|
146
|
+
value = @redis.get(key.to_s)
|
147
|
+
deserialize_value(value)
|
148
|
+
rescue StandardError => e
|
149
|
+
raise CacheError, "Redis read error: #{e.message}"
|
150
|
+
end
|
151
|
+
|
152
|
+
def write(key, value, expires_in: nil)
|
153
|
+
key_str = key.to_s
|
154
|
+
serialized_value = serialize_value(value)
|
155
|
+
|
156
|
+
if expires_in
|
157
|
+
@redis.setex(key_str, expires_in.to_i, serialized_value)
|
158
|
+
else
|
159
|
+
@redis.set(key_str, serialized_value)
|
160
|
+
end
|
161
|
+
|
162
|
+
true
|
163
|
+
rescue StandardError => e
|
164
|
+
raise CacheError, "Redis write error: #{e.message}"
|
165
|
+
end
|
166
|
+
|
167
|
+
def delete(key)
|
168
|
+
@redis.del(key.to_s).positive?
|
169
|
+
rescue StandardError => e
|
170
|
+
raise CacheError, "Redis delete error: #{e.message}"
|
171
|
+
end
|
172
|
+
|
173
|
+
def clear
|
174
|
+
@redis.flushdb
|
175
|
+
true
|
176
|
+
rescue StandardError => e
|
177
|
+
raise CacheError, "Redis clear error: #{e.message}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Memcached cache adapter
|
182
|
+
class MemcachedAdapter < CacheAdapter
|
183
|
+
def initialize(options = {})
|
184
|
+
super
|
185
|
+
require 'dalli' unless defined?(Dalli)
|
186
|
+
|
187
|
+
@memcached = Dalli::Client.new(options[:servers] || 'localhost:11211', options)
|
188
|
+
rescue LoadError
|
189
|
+
raise CacheError, "Dalli gem not found. Add 'gem \"dalli\"' to your Gemfile."
|
190
|
+
end
|
191
|
+
|
192
|
+
def read(key)
|
193
|
+
value = @memcached.get(key.to_s)
|
194
|
+
deserialize_value(value)
|
195
|
+
rescue StandardError => e
|
196
|
+
raise CacheError, "Memcached read error: #{e.message}"
|
197
|
+
end
|
198
|
+
|
199
|
+
def write(key, value, expires_in: nil)
|
200
|
+
serialized_value = serialize_value(value)
|
201
|
+
@memcached.set(key.to_s, serialized_value, expires_in&.to_i)
|
202
|
+
true
|
203
|
+
rescue StandardError => e
|
204
|
+
raise CacheError, "Memcached write error: #{e.message}"
|
205
|
+
end
|
206
|
+
|
207
|
+
def delete(key)
|
208
|
+
@memcached.delete(key.to_s)
|
209
|
+
true
|
210
|
+
rescue StandardError => e
|
211
|
+
raise CacheError, "Memcached delete error: #{e.message}"
|
212
|
+
end
|
213
|
+
|
214
|
+
def clear
|
215
|
+
@memcached.flush
|
216
|
+
true
|
217
|
+
rescue StandardError => e
|
218
|
+
raise CacheError, "Memcached clear error: #{e.message}"
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Solid Cache adapter (Rails 8+)
|
223
|
+
class SolidCacheAdapter < CacheAdapter
|
224
|
+
def initialize(options = {})
|
225
|
+
super
|
226
|
+
|
227
|
+
# Solid Cache should be available in Rails environment
|
228
|
+
unless defined?(SolidCache)
|
229
|
+
raise CacheError, "SolidCache not available. Ensure you're using Rails 8+ with Solid Cache configured."
|
230
|
+
end
|
231
|
+
|
232
|
+
@cache = options[:cache_instance] || SolidCache
|
233
|
+
end
|
234
|
+
|
235
|
+
def read(key)
|
236
|
+
value = @cache.read(key.to_s)
|
237
|
+
deserialize_value(value)
|
238
|
+
rescue StandardError => e
|
239
|
+
raise CacheError, "SolidCache read error: #{e.message}"
|
240
|
+
end
|
241
|
+
|
242
|
+
def write(key, value, expires_in: nil)
|
243
|
+
serialized_value = serialize_value(value)
|
244
|
+
@cache.write(key.to_s, serialized_value, expires_in: expires_in)
|
245
|
+
true
|
246
|
+
rescue StandardError => e
|
247
|
+
raise CacheError, "SolidCache write error: #{e.message}"
|
248
|
+
end
|
249
|
+
|
250
|
+
def delete(key)
|
251
|
+
@cache.delete(key.to_s)
|
252
|
+
true
|
253
|
+
rescue StandardError => e
|
254
|
+
raise CacheError, "SolidCache delete error: #{e.message}"
|
255
|
+
end
|
256
|
+
|
257
|
+
def clear
|
258
|
+
@cache.clear
|
259
|
+
true
|
260
|
+
rescue StandardError => e
|
261
|
+
raise CacheError, "SolidCache clear error: #{e.message}"
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RackJwtAegis
|
4
|
+
class Configuration
|
5
|
+
# Core JWT settings
|
6
|
+
attr_accessor :jwt_secret, :jwt_algorithm
|
7
|
+
|
8
|
+
# Feature toggles
|
9
|
+
attr_accessor :validate_subdomain, :validate_company_slug, :rbac_enabled
|
10
|
+
|
11
|
+
# Multi-tenant settings
|
12
|
+
attr_accessor :company_header_name, :company_slug_pattern, :payload_mapping
|
13
|
+
|
14
|
+
# Path management
|
15
|
+
attr_accessor :skip_paths
|
16
|
+
|
17
|
+
# Cache configuration
|
18
|
+
attr_accessor :cache_store, :cache_options, :cache_write_enabled
|
19
|
+
attr_accessor :rbac_cache_store, :rbac_cache_options, :permission_cache_store, :permission_cache_options
|
20
|
+
|
21
|
+
# Custom validators
|
22
|
+
attr_accessor :custom_payload_validator
|
23
|
+
|
24
|
+
# Response customization
|
25
|
+
attr_accessor :unauthorized_response, :forbidden_response
|
26
|
+
|
27
|
+
# Development settings
|
28
|
+
attr_accessor :debug_mode
|
29
|
+
|
30
|
+
def initialize(options = {})
|
31
|
+
# Set defaults
|
32
|
+
set_defaults
|
33
|
+
|
34
|
+
# Merge user options
|
35
|
+
options.each do |key, value|
|
36
|
+
raise ConfigurationError, "Unknown configuration option: #{key}" unless respond_to?("#{key}=")
|
37
|
+
|
38
|
+
public_send("#{key}=", value)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Validate configuration
|
42
|
+
validate!
|
43
|
+
end
|
44
|
+
|
45
|
+
def rbac_enabled?
|
46
|
+
config_boolean(rbac_enabled)
|
47
|
+
end
|
48
|
+
|
49
|
+
def validate_subdomain?
|
50
|
+
config_boolean(validate_subdomain)
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_company_slug?
|
54
|
+
config_boolean(validate_company_slug)
|
55
|
+
end
|
56
|
+
|
57
|
+
def debug_mode?
|
58
|
+
config_boolean(debug_mode)
|
59
|
+
end
|
60
|
+
|
61
|
+
def cache_write_enabled?
|
62
|
+
config_boolean(cache_write_enabled)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check if path should be skipped
|
66
|
+
def skip_path?(path)
|
67
|
+
return false if skip_paths.nil? || skip_paths.empty?
|
68
|
+
|
69
|
+
skip_paths.any? do |skip_path|
|
70
|
+
case skip_path
|
71
|
+
when String
|
72
|
+
path == skip_path
|
73
|
+
when Regexp
|
74
|
+
skip_path.match?(path)
|
75
|
+
else
|
76
|
+
false
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Get mapped payload key
|
82
|
+
def payload_key(standard_key)
|
83
|
+
payload_mapping&.fetch(standard_key, standard_key) || standard_key
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
# Convert various falsy/truthy values to proper boolean for configuration
|
89
|
+
def config_boolean(value)
|
90
|
+
return false if value.nil?
|
91
|
+
return false if value == false
|
92
|
+
return false if value == 0
|
93
|
+
return false if value == ''
|
94
|
+
return false if value.is_a?(String) && value.downcase.strip == 'false'
|
95
|
+
|
96
|
+
# Everything else is truthy
|
97
|
+
!!value
|
98
|
+
end
|
99
|
+
|
100
|
+
def set_defaults
|
101
|
+
@jwt_algorithm = 'HS256'
|
102
|
+
@validate_subdomain = false
|
103
|
+
@validate_company_slug = false
|
104
|
+
@rbac_enabled = false
|
105
|
+
@company_header_name = 'X-Company-Group-Id'
|
106
|
+
@company_slug_pattern = %r{^/api/v1/([^/]+)/}
|
107
|
+
@skip_paths = []
|
108
|
+
@cache_write_enabled = false
|
109
|
+
@debug_mode = false
|
110
|
+
@payload_mapping = {
|
111
|
+
user_id: :user_id,
|
112
|
+
company_group_id: :company_group_id,
|
113
|
+
company_group_domain: :company_group_domain,
|
114
|
+
company_slugs: :company_slugs,
|
115
|
+
}
|
116
|
+
@unauthorized_response = { error: 'Authentication required' }
|
117
|
+
@forbidden_response = { error: 'Access denied' }
|
118
|
+
end
|
119
|
+
|
120
|
+
def validate!
|
121
|
+
validate_jwt_settings!
|
122
|
+
validate_cache_settings!
|
123
|
+
validate_multi_tenant_settings!
|
124
|
+
end
|
125
|
+
|
126
|
+
def validate_jwt_settings!
|
127
|
+
raise ConfigurationError, 'jwt_secret is required' if jwt_secret.nil? || jwt_secret.empty?
|
128
|
+
|
129
|
+
valid_algorithms = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']
|
130
|
+
return if valid_algorithms.include?(jwt_algorithm)
|
131
|
+
|
132
|
+
raise ConfigurationError, "Unsupported JWT algorithm: #{jwt_algorithm}"
|
133
|
+
end
|
134
|
+
|
135
|
+
def validate_cache_settings!
|
136
|
+
return unless rbac_enabled?
|
137
|
+
|
138
|
+
# Validate cache store configuration
|
139
|
+
if cache_store && !cache_write_enabled?
|
140
|
+
# Zero trust mode - separate caches required
|
141
|
+
if rbac_cache_store.nil?
|
142
|
+
raise ConfigurationError, 'rbac_cache_store is required when cache_write_enabled is false'
|
143
|
+
end
|
144
|
+
|
145
|
+
if permission_cache_store.nil?
|
146
|
+
@permission_cache_store = :memory # Default fallback
|
147
|
+
end
|
148
|
+
elsif cache_store.nil? && rbac_cache_store.nil?
|
149
|
+
# Both cache stores are missing - at least one is required for RBAC
|
150
|
+
raise ConfigurationError, 'cache_store or rbac_cache_store is required when RBAC is enabled'
|
151
|
+
end
|
152
|
+
|
153
|
+
# Set default fallback for permission_cache_store when rbac_cache_store is provided
|
154
|
+
if !rbac_cache_store.nil? && permission_cache_store.nil?
|
155
|
+
@permission_cache_store = :memory # Default fallback
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def validate_multi_tenant_settings!
|
160
|
+
if validate_company_slug? && company_slug_pattern.nil?
|
161
|
+
raise ConfigurationError, 'company_slug_pattern is required when validate_company_slug is true'
|
162
|
+
end
|
163
|
+
|
164
|
+
if validate_subdomain? && !payload_mapping.key?(:company_group_domain)
|
165
|
+
raise ConfigurationError, 'payload_mapping must include :company_group_domain when validate_subdomain is true'
|
166
|
+
end
|
167
|
+
|
168
|
+
return unless validate_company_slug? && !payload_mapping.key?(:company_slugs)
|
169
|
+
|
170
|
+
raise ConfigurationError, 'payload_mapping must include :company_slugs when validate_company_slug is true'
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
module RackJwtAegis
|
6
|
+
class JwtValidator
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
def validate(token)
|
12
|
+
# Decode JWT with verification
|
13
|
+
payload, _header = JWT.decode(
|
14
|
+
token,
|
15
|
+
@config.jwt_secret,
|
16
|
+
true, # verify signature
|
17
|
+
{
|
18
|
+
algorithm: @config.jwt_algorithm,
|
19
|
+
verify_expiration: true,
|
20
|
+
verify_not_before: true,
|
21
|
+
verify_iat: true,
|
22
|
+
verify_aud: false, # Not validating audience by default
|
23
|
+
verify_iss: false, # Not validating issuer by default
|
24
|
+
verify_sub: false, # Not validating subject by default
|
25
|
+
},
|
26
|
+
)
|
27
|
+
|
28
|
+
# Validate payload structure
|
29
|
+
validate_payload_structure(payload)
|
30
|
+
|
31
|
+
payload
|
32
|
+
rescue JWT::ExpiredSignature
|
33
|
+
raise AuthenticationError, 'JWT token has expired'
|
34
|
+
rescue JWT::ImmatureSignature
|
35
|
+
raise AuthenticationError, 'JWT token not yet valid'
|
36
|
+
rescue JWT::InvalidIatError
|
37
|
+
raise AuthenticationError, 'JWT token issued in the future'
|
38
|
+
rescue JWT::DecodeError => e
|
39
|
+
raise AuthenticationError, "Invalid JWT token: #{e.message}"
|
40
|
+
rescue JWT::VerificationError
|
41
|
+
raise AuthenticationError, 'JWT signature verification failed'
|
42
|
+
rescue StandardError => e
|
43
|
+
raise AuthenticationError, "JWT validation error: #{e.message}"
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def validate_payload_structure(payload)
|
49
|
+
# Ensure payload is a hash
|
50
|
+
raise AuthenticationError, 'Invalid JWT payload structure' unless payload.is_a?(Hash)
|
51
|
+
|
52
|
+
# Validate required claims based on enabled features
|
53
|
+
validate_required_claims(payload)
|
54
|
+
|
55
|
+
# Validate claim types
|
56
|
+
validate_claim_types(payload)
|
57
|
+
end
|
58
|
+
|
59
|
+
def validate_required_claims(payload)
|
60
|
+
required_claims = []
|
61
|
+
|
62
|
+
# Always require user identification
|
63
|
+
required_claims << @config.payload_key(:user_id)
|
64
|
+
|
65
|
+
# Multi-tenant validation requirements
|
66
|
+
if @config.validate_subdomain?
|
67
|
+
required_claims << @config.payload_key(:company_group_id)
|
68
|
+
required_claims << @config.payload_key(:company_group_domain)
|
69
|
+
end
|
70
|
+
|
71
|
+
required_claims << @config.payload_key(:company_slugs) if @config.validate_company_slug?
|
72
|
+
|
73
|
+
missing_claims = required_claims.select { |claim| payload[claim.to_s].nil? }
|
74
|
+
|
75
|
+
return if missing_claims.empty?
|
76
|
+
|
77
|
+
raise AuthenticationError, "JWT payload missing required claims: #{missing_claims.join(', ')}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def validate_claim_types(payload)
|
81
|
+
user_id_key = @config.payload_key(:user_id).to_s
|
82
|
+
|
83
|
+
# User ID should be numeric or string
|
84
|
+
if payload[user_id_key] && !payload[user_id_key].is_a?(Numeric) && !payload[user_id_key].is_a?(String)
|
85
|
+
raise AuthenticationError, 'Invalid user_id format in JWT payload'
|
86
|
+
end
|
87
|
+
|
88
|
+
# Company group ID should be numeric or string (if present)
|
89
|
+
if @config.validate_subdomain?
|
90
|
+
company_group_id_key = @config.payload_key(:company_group_id).to_s
|
91
|
+
if payload[company_group_id_key] && !payload[company_group_id_key].is_a?(Numeric) && !payload[company_group_id_key].is_a?(String)
|
92
|
+
raise AuthenticationError, 'Invalid company_group_id format in JWT payload'
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Company group domain should be string (if present)
|
97
|
+
if @config.validate_subdomain?
|
98
|
+
company_domain_key = @config.payload_key(:company_group_domain).to_s
|
99
|
+
if payload[company_domain_key] && !payload[company_domain_key].is_a?(String)
|
100
|
+
raise AuthenticationError, 'Invalid company_group_domain format in JWT payload'
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Company slugs should be array (if present)
|
105
|
+
return unless @config.validate_company_slug?
|
106
|
+
|
107
|
+
company_slugs_key = @config.payload_key(:company_slugs).to_s
|
108
|
+
return unless payload[company_slugs_key] && !payload[company_slugs_key].is_a?(Array)
|
109
|
+
|
110
|
+
raise AuthenticationError, 'Invalid company_slugs format in JWT payload - must be array'
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RackJwtAegis
|
4
|
+
class Middleware
|
5
|
+
def initialize(app, options = {})
|
6
|
+
@app = app
|
7
|
+
@config = Configuration.new(options)
|
8
|
+
|
9
|
+
# Initialize components
|
10
|
+
@jwt_validator = JwtValidator.new(@config)
|
11
|
+
@multi_tenant_validator = MultiTenantValidator.new(@config) if multi_tenant_enabled?
|
12
|
+
@rbac_manager = RbacManager.new(@config) if @config.rbac_enabled?
|
13
|
+
@response_builder = ResponseBuilder.new(@config)
|
14
|
+
@request_context = RequestContext.new(@config)
|
15
|
+
|
16
|
+
debug_log("Middleware initialized with features: #{enabled_features}")
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
request = Rack::Request.new(env)
|
21
|
+
|
22
|
+
debug_log("Processing request: #{request.request_method} #{request.path}")
|
23
|
+
|
24
|
+
# Step 1: Check if path should be skipped
|
25
|
+
if @config.skip_path?(request.path)
|
26
|
+
debug_log("Skipping authentication for path: #{request.path}")
|
27
|
+
return @app.call(env)
|
28
|
+
end
|
29
|
+
|
30
|
+
begin
|
31
|
+
# Step 2: Extract and validate JWT token
|
32
|
+
token = extract_jwt_token(request)
|
33
|
+
payload = @jwt_validator.validate(token)
|
34
|
+
|
35
|
+
debug_log("JWT validation successful for user: #{payload[@config.payload_key(:user_id)]}")
|
36
|
+
|
37
|
+
# Step 3: Multi-tenant validation (if enabled)
|
38
|
+
if multi_tenant_enabled?
|
39
|
+
@multi_tenant_validator.validate(request, payload)
|
40
|
+
debug_log('Multi-tenant validation successful')
|
41
|
+
end
|
42
|
+
|
43
|
+
# Step 4: RBAC permission check (if enabled)
|
44
|
+
if @config.rbac_enabled?
|
45
|
+
@rbac_manager.authorize(request, payload)
|
46
|
+
debug_log('RBAC authorization successful')
|
47
|
+
end
|
48
|
+
|
49
|
+
# Step 5: Custom payload validation (if configured)
|
50
|
+
if @config.custom_payload_validator
|
51
|
+
unless @config.custom_payload_validator.call(payload, request)
|
52
|
+
debug_log('Custom payload validation failed')
|
53
|
+
raise AuthorizationError, 'Custom validation failed'
|
54
|
+
end
|
55
|
+
debug_log('Custom payload validation successful')
|
56
|
+
end
|
57
|
+
|
58
|
+
# Step 6: Set request context for application
|
59
|
+
@request_context.set_context(env, payload)
|
60
|
+
debug_log('Request context set successfully')
|
61
|
+
|
62
|
+
# Continue to application
|
63
|
+
@app.call(env)
|
64
|
+
rescue AuthenticationError => e
|
65
|
+
debug_log("Authentication failed: #{e.message}")
|
66
|
+
@response_builder.unauthorized_response(e.message)
|
67
|
+
rescue AuthorizationError => e
|
68
|
+
debug_log("Authorization failed: #{e.message}")
|
69
|
+
@response_builder.forbidden_response(e.message)
|
70
|
+
rescue StandardError => e
|
71
|
+
debug_log("Unexpected error: #{e.message}")
|
72
|
+
if @config.debug_mode?
|
73
|
+
@response_builder.error_response("Internal error: #{e.message}", 500)
|
74
|
+
else
|
75
|
+
@response_builder.error_response('Internal server error', 500)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def extract_jwt_token(request)
|
83
|
+
auth_header = request.get_header('HTTP_AUTHORIZATION')
|
84
|
+
|
85
|
+
raise AuthenticationError, 'Authorization header missing' if auth_header.nil? || auth_header.empty?
|
86
|
+
|
87
|
+
# Extract Bearer token
|
88
|
+
match = auth_header.match(/\ABearer\s+(.+)\z/)
|
89
|
+
raise AuthenticationError, 'Invalid authorization header format' if match.nil?
|
90
|
+
|
91
|
+
token = match[1]
|
92
|
+
raise AuthenticationError, 'JWT token missing' if token.nil? || token.empty?
|
93
|
+
|
94
|
+
token
|
95
|
+
end
|
96
|
+
|
97
|
+
def multi_tenant_enabled?
|
98
|
+
@config.validate_subdomain? || @config.validate_company_slug?
|
99
|
+
end
|
100
|
+
|
101
|
+
def enabled_features
|
102
|
+
features = ['JWT']
|
103
|
+
features << 'Subdomain' if @config.validate_subdomain?
|
104
|
+
features << 'CompanySlug' if @config.validate_company_slug?
|
105
|
+
features << 'RBAC' if @config.rbac_enabled?
|
106
|
+
features.join(', ')
|
107
|
+
end
|
108
|
+
|
109
|
+
def debug_log(message)
|
110
|
+
return unless @config.debug_mode?
|
111
|
+
|
112
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
|
113
|
+
puts "[#{timestamp}] RackJwtAegis: #{message}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|