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.
@@ -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