otto 1.6.0 → 2.0.0.pre1

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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +10 -3
  10. data/Gemfile.lock +23 -28
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +29 -34
  66. data/examples/mcp_demo/config.ru +9 -60
  67. data/examples/security_features/README.md +46 -0
  68. data/examples/security_features/app.rb +23 -24
  69. data/examples/security_features/config.ru +8 -10
  70. data/lib/otto/core/configuration.rb +167 -0
  71. data/lib/otto/core/error_handler.rb +86 -0
  72. data/lib/otto/core/file_safety.rb +61 -0
  73. data/lib/otto/core/middleware_stack.rb +157 -0
  74. data/lib/otto/core/router.rb +183 -0
  75. data/lib/otto/core/uri_generator.rb +44 -0
  76. data/lib/otto/design_system.rb +7 -5
  77. data/lib/otto/helpers/base.rb +3 -0
  78. data/lib/otto/helpers/request.rb +10 -8
  79. data/lib/otto/helpers/response.rb +5 -4
  80. data/lib/otto/helpers/validation.rb +9 -7
  81. data/lib/otto/mcp/auth/token.rb +10 -9
  82. data/lib/otto/mcp/protocol.rb +24 -27
  83. data/lib/otto/mcp/rate_limiting.rb +8 -3
  84. data/lib/otto/mcp/registry.rb +7 -2
  85. data/lib/otto/mcp/route_parser.rb +10 -15
  86. data/lib/otto/mcp/server.rb +21 -11
  87. data/lib/otto/mcp/validation.rb +14 -10
  88. data/lib/otto/response_handlers/auto.rb +39 -0
  89. data/lib/otto/response_handlers/base.rb +16 -0
  90. data/lib/otto/response_handlers/default.rb +16 -0
  91. data/lib/otto/response_handlers/factory.rb +39 -0
  92. data/lib/otto/response_handlers/json.rb +28 -0
  93. data/lib/otto/response_handlers/redirect.rb +25 -0
  94. data/lib/otto/response_handlers/view.rb +24 -0
  95. data/lib/otto/response_handlers.rb +9 -135
  96. data/lib/otto/route.rb +9 -9
  97. data/lib/otto/route_definition.rb +15 -18
  98. data/lib/otto/route_handlers/base.rb +121 -0
  99. data/lib/otto/route_handlers/class_method.rb +89 -0
  100. data/lib/otto/route_handlers/factory.rb +29 -0
  101. data/lib/otto/route_handlers/instance_method.rb +69 -0
  102. data/lib/otto/route_handlers/lambda.rb +59 -0
  103. data/lib/otto/route_handlers/logic_class.rb +93 -0
  104. data/lib/otto/route_handlers.rb +10 -405
  105. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  106. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  107. data/lib/otto/security/authentication/failure_result.rb +36 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  110. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -12
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +142 -498
  128. data/otto.gemspec +2 -2
  129. metadata +89 -28
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -1,412 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/route_handlers.rb
2
4
 
3
5
  class Otto
4
- # Pluggable Route Handler Factory (Phase 4)
6
+ # Pluggable Route Handler Factory
7
+ #
5
8
  # Enables different execution patterns while maintaining backward compatibility
6
9
  module RouteHandlers
7
- # Factory for creating appropriate handlers based on route definitions
8
- class HandlerFactory
9
- # Create a handler for the given route definition
10
- # @param route_definition [Otto::RouteDefinition] The route definition
11
- # @param otto_instance [Otto] The Otto instance for configuration access
12
- # @return [BaseHandler] Appropriate handler for the route
13
- def self.create_handler(route_definition, otto_instance = nil)
14
- case route_definition.kind
15
- when :logic
16
- LogicClassHandler.new(route_definition, otto_instance)
17
- when :instance
18
- InstanceMethodHandler.new(route_definition, otto_instance)
19
- when :class
20
- ClassMethodHandler.new(route_definition, otto_instance)
21
- else
22
- raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
23
- end
24
- end
25
- end
26
-
27
- # Base class for all route handlers
28
- # Provides common functionality and interface
29
- class BaseHandler
30
- attr_reader :route_definition, :otto_instance
31
-
32
- def initialize(route_definition, otto_instance = nil)
33
- @route_definition = route_definition
34
- @otto_instance = otto_instance
35
- end
36
-
37
- # Execute the route handler
38
- # @param env [Hash] Rack environment
39
- # @param extra_params [Hash] Additional parameters
40
- # @return [Array] Rack response array
41
- def call(env, extra_params = {})
42
- raise NotImplementedError, 'Subclasses must implement #call'
43
- end
44
-
45
- protected
46
-
47
- # Get the target class, loading it safely
48
- # @return [Class] The target class
49
- def target_class
50
- @target_class ||= safe_const_get(route_definition.klass_name)
51
- end
52
-
53
- # Setup request and response with the same extensions and processing as Route#call
54
- # @param req [Rack::Request] Request object
55
- # @param res [Rack::Response] Response object
56
- # @param env [Hash] Rack environment
57
- # @param extra_params [Hash] Additional parameters
58
- def setup_request_response(req, res, env, extra_params)
59
- # Apply the same extensions as original Route#call
60
- req.extend Otto::RequestHelpers
61
- res.extend Otto::ResponseHelpers
62
- res.request = req
63
-
64
- # Make security config available to response helpers
65
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
66
- env['otto.security_config'] = otto_instance.security_config
67
- end
68
-
69
- # Make route definition and options available to middleware and handlers
70
- env['otto.route_definition'] = route_definition
71
- env['otto.route_options'] = route_definition.options
72
-
73
- # Process parameters through security layer
74
- req.params.merge! extra_params
75
- req.params.replace Otto::Static.indifferent_params(req.params)
76
-
77
- # Add security headers
78
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
79
- otto_instance.security_config.security_headers.each do |header, value|
80
- res.headers[header] = value
81
- end
82
- end
83
-
84
- # Setup class extensions if target_class is available
85
- if target_class
86
- target_class.extend Otto::Route::ClassMethods
87
- target_class.otto = otto_instance if otto_instance
88
- end
89
-
90
- # Add security helpers if CSRF is enabled
91
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config&.csrf_enabled?
92
- res.extend Otto::Security::CSRFHelpers
93
- end
94
-
95
- # Add validation helpers
96
- res.extend Otto::Security::ValidationHelpers
97
- end
98
-
99
- # Finalize response with the same processing as Route#call
100
- # @param res [Rack::Response] Response object
101
- # @return [Array] Rack response array
102
- def finalize_response(res)
103
- res.body = [res.body] unless res.body.respond_to?(:each)
104
- res.finish
105
- end
106
-
107
- # Handle response using appropriate response handler
108
- # @param result [Object] Result from route execution
109
- # @param response [Rack::Response] Response object
110
- # @param context [Hash] Additional context for response handling
111
- def handle_response(result, response, context = {})
112
- response_type = route_definition.response_type
113
-
114
- # Get the appropriate response handler
115
- handler_class = case response_type
116
- when 'json' then Otto::ResponseHandlers::JSONHandler
117
- when 'redirect' then Otto::ResponseHandlers::RedirectHandler
118
- when 'view' then Otto::ResponseHandlers::ViewHandler
119
- when 'auto' then Otto::ResponseHandlers::AutoHandler
120
- else Otto::ResponseHandlers::DefaultHandler
121
- end
122
-
123
- handler_class.handle(result, response, context)
124
- end
125
-
126
- private
127
-
128
- # Safely get a constant from a string name
129
- # @param name [String] Class name
130
- # @return [Class] The class
131
- def safe_const_get(name)
132
- name.split('::').inject(Object) do |scope, const_name|
133
- scope.const_get(const_name)
134
- end
135
- rescue NameError => ex
136
- raise NameError, "Unknown class: #{name} (#{ex})"
137
- end
138
- end
139
-
140
- # Handler for Logic classes (new in Otto Framework Enhancement)
141
- # Supports the OneTime Secret Logic class pattern
142
- class LogicClassHandler < BaseHandler
143
- def call(env, extra_params = {})
144
- req = Rack::Request.new(env)
145
- res = Rack::Response.new
146
-
147
- begin
148
- # Get authentication context if available
149
- auth_result = env['otto.auth_result']
150
-
151
- # Initialize Logic class with standard parameters
152
- # Logic classes expect: session, user, params, locale
153
- logic_params = req.params.merge(extra_params)
154
- locale = env['otto.locale'] || 'en'
155
-
156
- logic = if target_class.instance_method(:initialize).arity == 4
157
- # Standard Logic class constructor
158
- target_class.new(
159
- auth_result&.session,
160
- auth_result&.user,
161
- logic_params,
162
- locale,
163
- )
164
- else
165
- # Fallback for custom constructors
166
- target_class.new(req, res)
167
- end
168
-
169
- # Execute standard Logic class lifecycle
170
- if logic.respond_to?(:raise_concerns)
171
- logic.raise_concerns
172
- end
173
-
174
- result = if logic.respond_to?(:process)
175
- logic.process
176
- else
177
- logic.call || logic
178
- end
179
-
180
- # Handle response with Logic instance context
181
- handle_response(result, res, {
182
- logic_instance: logic,
183
- request: req,
184
- status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
185
- }
186
- )
187
- rescue StandardError => ex
188
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
189
- # In integrated context, let Otto's centralized error handler manage the response
190
- # In direct testing context, handle errors locally for unit testing
191
- if otto_instance
192
- # Log error for handler-specific context but let Otto's centralized error handler manage the response
193
- Otto.logger.error "[LogicClassHandler] #{ex.class}: #{ex.message}"
194
- Otto.logger.debug "[LogicClassHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
195
- raise ex # Re-raise to let Otto's centralized error handler manage the response
196
- else
197
- # Direct handler testing context - handle errors locally with security improvements
198
- error_id = SecureRandom.hex(8)
199
- Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
200
- Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
201
-
202
- res.status = 500
203
- res.headers['content-type'] = 'text/plain'
204
-
205
- if Otto.env?(:dev, :development)
206
- res.write "Server error (ID: #{error_id}). Check logs for details."
207
- else
208
- res.write 'An error occurred. Please try again later.'
209
- end
210
-
211
- # Add security headers if available
212
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
213
- otto_instance.security_config.security_headers.each do |header, value|
214
- res.headers[header] = value
215
- end
216
- end
217
- end
218
- end
219
-
220
- res.finish
221
- end
222
- end
223
-
224
- # Handler for instance methods (existing Otto pattern)
225
- # Maintains backward compatibility for Controller#action patterns
226
- class InstanceMethodHandler < BaseHandler
227
- def call(env, extra_params = {})
228
- req = Rack::Request.new(env)
229
- res = Rack::Response.new
230
-
231
- begin
232
- # Apply the same extensions and processing as original Route#call
233
- setup_request_response(req, res, env, extra_params)
234
-
235
- # Create instance and call method (existing Otto behavior)
236
- instance = target_class.new(req, res)
237
- result = instance.send(route_definition.method_name)
238
-
239
- # Only handle response if response_type is not default
240
- if route_definition.response_type != 'default'
241
- handle_response(result, res, {
242
- instance: instance,
243
- request: req,
244
- }
245
- )
246
- end
247
- rescue StandardError => ex
248
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
249
- # In integrated context, let Otto's centralized error handler manage the response
250
- # In direct testing context, handle errors locally for unit testing
251
- if otto_instance
252
- # Log error for handler-specific context but let Otto's centralized error handler manage the response
253
- Otto.logger.error "[InstanceMethodHandler] #{ex.class}: #{ex.message}"
254
- Otto.logger.debug "[InstanceMethodHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
255
- raise ex # Re-raise to let Otto's centralized error handler manage the response
256
- else
257
- # Direct handler testing context - handle errors locally with security improvements
258
- error_id = SecureRandom.hex(8)
259
- Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
260
- Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
261
-
262
- res.status = 500
263
- res.headers['content-type'] = 'text/plain'
264
-
265
- if Otto.env?(:dev, :development)
266
- res.write "Server error (ID: #{error_id}). Check logs for details."
267
- else
268
- res.write 'An error occurred. Please try again later.'
269
- end
270
-
271
- # Add security headers if available
272
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
273
- otto_instance.security_config.security_headers.each do |header, value|
274
- res.headers[header] = value
275
- end
276
- end
277
- end
278
- end
279
-
280
- finalize_response(res)
281
- end
282
- end
283
-
284
- # Handler for class methods (existing Otto pattern)
285
- # Maintains backward compatibility for Controller.action patterns
286
- class ClassMethodHandler < BaseHandler
287
- def call(env, extra_params = {})
288
- req = Rack::Request.new(env)
289
- res = Rack::Response.new
290
-
291
- begin
292
- # Apply the same extensions and processing as original Route#call
293
- setup_request_response(req, res, env, extra_params)
294
-
295
- # Call class method directly (existing Otto behavior)
296
- result = target_class.send(route_definition.method_name, req, res)
297
-
298
- # Only handle response if response_type is not default
299
- if route_definition.response_type != 'default'
300
- handle_response(result, res, {
301
- class: target_class,
302
- request: req,
303
- }
304
- )
305
- end
306
- rescue StandardError => ex
307
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
308
- # In integrated context, let Otto's centralized error handler manage the response
309
- # In direct testing context, handle errors locally for unit testing
310
- if otto_instance
311
- # Log error for handler-specific context but let Otto's centralized error handler manage the response
312
- Otto.logger.error "[ClassMethodHandler] #{ex.class}: #{ex.message}"
313
- Otto.logger.debug "[ClassMethodHandler] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
314
- raise ex # Re-raise to let Otto's centralized error handler manage the response
315
- else
316
- # Direct handler testing context - handle errors locally with security improvements
317
- error_id = SecureRandom.hex(8)
318
- Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
319
- Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
320
-
321
- res.status = 500
322
-
323
- # Content negotiation for error response
324
- accept_header = env['HTTP_ACCEPT'].to_s
325
- if accept_header.include?('application/json')
326
- res.headers['content-type'] = 'application/json'
327
- error_data = if Otto.env?(:dev, :development)
328
- {
329
- error: 'Internal Server Error',
330
- message: 'Server error occurred. Check logs for details.',
331
- error_id: error_id,
332
- }
333
- else
334
- {
335
- error: 'Internal Server Error',
336
- message: 'An error occurred. Please try again later.',
337
- }
338
- end
339
- res.write JSON.generate(error_data)
340
- else
341
- res.headers['content-type'] = 'text/plain'
342
- if Otto.env?(:dev, :development)
343
- res.write "Server error (ID: #{error_id}). Check logs for details."
344
- else
345
- res.write 'An error occurred. Please try again later.'
346
- end
347
- end
348
-
349
- # Add security headers if available
350
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
351
- otto_instance.security_config.security_headers.each do |header, value|
352
- res.headers[header] = value
353
- end
354
- end
355
- end
356
- end
357
-
358
- finalize_response(res)
359
- end
360
- end
361
-
362
- # Custom handler for lambda/proc definitions (future extension)
363
- class LambdaHandler < BaseHandler
364
- def call(env, extra_params = {})
365
- req = Rack::Request.new(env)
366
- res = Rack::Response.new
367
-
368
- begin
369
- # Security: Lambda handlers require pre-configured procs from Otto instance
370
- # This prevents code injection via eval and maintains security
371
- handler_name = route_definition.klass_name
372
- lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
373
-
374
- lambda_proc = lambda_registry[handler_name]
375
- unless lambda_proc.respond_to?(:call)
376
- raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
377
- end
378
-
379
- result = lambda_proc.call(req, res, extra_params)
380
-
381
- handle_response(result, res, {
382
- lambda: lambda_proc,
383
- request: req,
384
- }
385
- )
386
- rescue StandardError => ex
387
- error_id = SecureRandom.hex(8)
388
- Otto.logger.error "[#{error_id}] #{ex.class}: #{ex.message}"
389
- Otto.logger.debug "[#{error_id}] Backtrace: #{ex.backtrace.join("\n")}" if Otto.debug
390
-
391
- res.status = 500
392
- res.headers['content-type'] = 'text/plain'
393
-
394
- if Otto.env?(:dev, :development)
395
- res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
396
- else
397
- res.write 'An error occurred. Please try again later.'
398
- end
399
-
400
- # Add security headers if available
401
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
402
- otto_instance.security_config.security_headers.each do |header, value|
403
- res.headers[header] = value
404
- end
405
- end
406
- end
407
-
408
- res.finish
409
- end
410
- end
10
+ require_relative 'route_handlers/base'
11
+ require_relative 'route_handlers/factory'
12
+ require_relative 'route_handlers/logic_class'
13
+ require_relative 'route_handlers/instance_method'
14
+ require_relative 'route_handlers/class_method'
15
+ require_relative 'route_handlers/lambda'
411
16
  end
412
17
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/authentication/auth_strategy.rb
4
+ #
5
+ # Base class for all authentication strategies in Otto framework
6
+ # Provides pluggable authentication patterns that can be customized per application
7
+
8
+ class Otto
9
+ module Security
10
+ module Authentication
11
+ # Base class for all authentication strategies
12
+ class AuthStrategy
13
+ # Check if the request meets the authentication requirements
14
+ # @param env [Hash] Rack environment
15
+ # @param requirement [String] Authentication requirement string
16
+ # @return [Otto::Security::Authentication::StrategyResult, nil] StrategyResult for success, nil for failure
17
+ def authenticate(env, requirement)
18
+ raise NotImplementedError, 'Subclasses must implement #authenticate'
19
+ end
20
+
21
+ protected
22
+
23
+ # Helper to create successful strategy result
24
+ def success(user:, session: {}, auth_method: nil, **metadata)
25
+ Otto::Security::Authentication::StrategyResult.new(
26
+ session: session,
27
+ user: user,
28
+ auth_method: auth_method || self.class.name.split('::').last,
29
+ metadata: metadata
30
+ )
31
+ end
32
+
33
+ # Helper for authentication failure - return FailureResult
34
+ def failure(reason = nil)
35
+ Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
36
+ Otto::Security::Authentication::FailureResult.new(
37
+ failure_reason: reason || 'Authentication failed',
38
+ auth_method: self.class.name.split('::').last
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'strategy_result'
4
+ require_relative 'failure_result'
5
+ require_relative 'strategies/public_strategy'
6
+ require_relative 'strategies/role_strategy'
7
+ require_relative 'strategies/permission_strategy'
8
+
9
+ class Otto
10
+ module Security
11
+ module Authentication
12
+ # Authentication middleware that enforces route-level auth requirements
13
+ class AuthenticationMiddleware
14
+ def initialize(app, security_config = {}, config = {})
15
+ @app = app
16
+ @security_config = security_config
17
+ @config = config
18
+ @strategies = config[:auth_strategies] || {}
19
+ @default_strategy = config[:default_auth_strategy] || 'publicly'
20
+
21
+ # Add default public strategy if not provided
22
+ @strategies['publicly'] ||= Strategies::PublicStrategy.new
23
+ end
24
+
25
+ def call(env)
26
+ # Check if this route has auth requirements
27
+ route_definition = env['otto.route_definition']
28
+
29
+ # If no route definition, create anonymous result and continue
30
+ unless route_definition
31
+ env['otto.strategy_result'] = Otto::Security::Authentication::StrategyResult.anonymous(
32
+ metadata: { ip: env['REMOTE_ADDR'] }
33
+ )
34
+ return @app.call(env)
35
+ end
36
+
37
+ auth_requirement = route_definition.auth_requirement
38
+
39
+ # If no auth requirement, create anonymous result and continue
40
+ unless auth_requirement
41
+ env['otto.strategy_result'] = Otto::Security::Authentication::StrategyResult.anonymous(
42
+ metadata: { ip: env['REMOTE_ADDR'] }
43
+ )
44
+ return @app.call(env)
45
+ end
46
+
47
+ # Find appropriate strategy
48
+ strategy = find_strategy(auth_requirement)
49
+ return auth_error_response("Unknown authentication strategy: #{auth_requirement}") unless strategy
50
+
51
+ # Perform authentication
52
+ strategy_result = strategy.authenticate(env, auth_requirement)
53
+
54
+ if strategy_result&.success?
55
+ # Success - store the strategy result directly
56
+ env['otto.strategy_result'] = strategy_result
57
+ env['otto.user'] = strategy_result.user # For convenience
58
+ env['otto.user_context'] = strategy_result.user_context # For convenience
59
+ @app.call(env)
60
+ else
61
+ # Failure - create anonymous result with failure info
62
+ failure_reason = strategy_result&.failure_reason || 'Authentication failed'
63
+ env['otto.strategy_result'] = Otto::Security::Authentication::StrategyResult.anonymous(
64
+ metadata: {
65
+ ip: env['REMOTE_ADDR'],
66
+ auth_failure: failure_reason,
67
+ attempted_strategy: auth_requirement,
68
+ }
69
+ )
70
+ auth_error_response(failure_reason)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def find_strategy(requirement)
77
+ # Try exact match first - this has highest priority
78
+ return @strategies[requirement] if @strategies[requirement]
79
+
80
+ # For colon-separated requirements like "role:admin", try prefix match
81
+ if requirement.include?(':')
82
+ prefix = requirement.split(':', 2).first
83
+
84
+ # Check if we have a strategy registered for the prefix
85
+ prefix_strategy = @strategies[prefix]
86
+ return prefix_strategy if prefix_strategy
87
+
88
+ # Try fallback patterns for role: and permission: requirements
89
+ if requirement.start_with?('role:')
90
+ return @strategies['role'] || Strategies::RoleStrategy.new([])
91
+ elsif requirement.start_with?('permission:')
92
+ return @strategies['permission'] || Strategies::PermissionStrategy.new([])
93
+ end
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ def auth_error_response(message)
100
+ body = JSON.generate({
101
+ error: 'Authentication Required',
102
+ message: message,
103
+ timestamp: Time.now.to_i,
104
+ })
105
+
106
+ headers = {
107
+ 'Content-Type' => 'application/json',
108
+ 'Content-Length' => body.bytesize.to_s,
109
+ }
110
+
111
+ # Add security headers if available from config hash or Otto instance
112
+ if @config.is_a?(Hash) && @config[:security_headers]
113
+ headers.merge!(@config[:security_headers])
114
+ elsif @config.respond_to?(:security_config) && @config.security_config
115
+ headers.merge!(@config.security_config.security_headers)
116
+ end
117
+
118
+ [401, headers, [body]]
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/authentication/failure_result.rb
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ # Failure result for authentication failures
9
+ FailureResult = Data.define(:failure_reason, :auth_method) do
10
+ def success?
11
+ false
12
+ end
13
+
14
+ def failure?
15
+ true
16
+ end
17
+
18
+ def authenticated?
19
+ false
20
+ end
21
+
22
+ def anonymous?
23
+ true
24
+ end
25
+
26
+ def user_context
27
+ {}
28
+ end
29
+
30
+ def inspect
31
+ "#<FailureResult reason=#{failure_reason.inspect} method=#{auth_method}>"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # API key authentication strategy
10
+ class APIKeyStrategy < AuthStrategy
11
+ def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
12
+ @api_keys = Array(api_keys)
13
+ @header_name = header_name
14
+ @param_name = param_name
15
+ end
16
+
17
+ def authenticate(env, _requirement)
18
+ # Try header first, then query parameter
19
+ api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
20
+
21
+ if api_key.nil?
22
+ request = Rack::Request.new(env)
23
+ api_key = request.params[@param_name]
24
+ end
25
+
26
+ return failure('No API key provided') unless api_key
27
+
28
+ if @api_keys.empty? || @api_keys.include?(api_key)
29
+ # Create a simple user hash for API key authentication
30
+ user_data = { api_key: api_key }
31
+ success(user: user_data, api_key: api_key)
32
+ else
33
+ failure('Invalid API key')
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end