otto 1.5.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 +44 -5
  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 +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  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 +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
@@ -1,383 +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
-
8
- # Factory for creating appropriate handlers based on route definitions
9
- class HandlerFactory
10
- # Create a handler for the given route definition
11
- # @param route_definition [Otto::RouteDefinition] The route definition
12
- # @param otto_instance [Otto] The Otto instance for configuration access
13
- # @return [BaseHandler] Appropriate handler for the route
14
- def self.create_handler(route_definition, otto_instance = nil)
15
- case route_definition.kind
16
- when :logic
17
- LogicClassHandler.new(route_definition, otto_instance)
18
- when :instance
19
- InstanceMethodHandler.new(route_definition, otto_instance)
20
- when :class
21
- ClassMethodHandler.new(route_definition, otto_instance)
22
- else
23
- raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
24
- end
25
- end
26
- end
27
-
28
- # Base class for all route handlers
29
- # Provides common functionality and interface
30
- class BaseHandler
31
- attr_reader :route_definition, :otto_instance
32
-
33
- def initialize(route_definition, otto_instance = nil)
34
- @route_definition = route_definition
35
- @otto_instance = otto_instance
36
- end
37
-
38
- # Execute the route handler
39
- # @param env [Hash] Rack environment
40
- # @param extra_params [Hash] Additional parameters
41
- # @return [Array] Rack response array
42
- def call(env, extra_params = {})
43
- raise NotImplementedError, "Subclasses must implement #call"
44
- end
45
-
46
- protected
47
-
48
- # Get the target class, loading it safely
49
- # @return [Class] The target class
50
- def target_class
51
- @target_class ||= safe_const_get(route_definition.klass_name)
52
- end
53
-
54
- # Setup request and response with the same extensions and processing as Route#call
55
- # @param req [Rack::Request] Request object
56
- # @param res [Rack::Response] Response object
57
- # @param env [Hash] Rack environment
58
- # @param extra_params [Hash] Additional parameters
59
- def setup_request_response(req, res, env, extra_params)
60
- # Apply the same extensions as original Route#call
61
- req.extend Otto::RequestHelpers
62
- res.extend Otto::ResponseHelpers
63
- res.request = req
64
-
65
- # Make security config available to response helpers
66
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
67
- env['otto.security_config'] = otto_instance.security_config
68
- end
69
-
70
- # Make route definition and options available to middleware and handlers
71
- env['otto.route_definition'] = route_definition
72
- env['otto.route_options'] = route_definition.options
73
-
74
- # Process parameters through security layer
75
- req.params.merge! extra_params
76
- req.params.replace Otto::Static.indifferent_params(req.params)
77
-
78
- # Add security headers
79
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
80
- otto_instance.security_config.security_headers.each do |header, value|
81
- res.headers[header] = value
82
- end
83
- end
84
-
85
- # Setup class extensions if target_class is available
86
- if target_class
87
- target_class.extend Otto::Route::ClassMethods
88
- target_class.otto = otto_instance if otto_instance
89
- end
90
-
91
- # Add security helpers if CSRF is enabled
92
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config&.csrf_enabled?
93
- res.extend Otto::Security::CSRFHelpers
94
- end
95
-
96
- # Add validation helpers
97
- res.extend Otto::Security::ValidationHelpers
98
- end
99
-
100
- # Finalize response with the same processing as Route#call
101
- # @param res [Rack::Response] Response object
102
- # @return [Array] Rack response array
103
- def finalize_response(res)
104
- res.body = [res.body] unless res.body.respond_to?(:each)
105
- res.finish
106
- end
107
-
108
- # Handle response using appropriate response handler
109
- # @param result [Object] Result from route execution
110
- # @param response [Rack::Response] Response object
111
- # @param context [Hash] Additional context for response handling
112
- def handle_response(result, response, context = {})
113
- response_type = route_definition.response_type
114
-
115
- # Get the appropriate response handler
116
- handler_class = case response_type
117
- when 'json' then Otto::ResponseHandlers::JSONHandler
118
- when 'redirect' then Otto::ResponseHandlers::RedirectHandler
119
- when 'view' then Otto::ResponseHandlers::ViewHandler
120
- when 'auto' then Otto::ResponseHandlers::AutoHandler
121
- else Otto::ResponseHandlers::DefaultHandler
122
- end
123
-
124
- handler_class.handle(result, response, context)
125
- end
126
-
127
- private
128
-
129
- # Safely get a constant from a string name
130
- # @param name [String] Class name
131
- # @return [Class] The class
132
- def safe_const_get(name)
133
- name.split('::').inject(Object) do |scope, const_name|
134
- scope.const_get(const_name)
135
- end
136
- rescue NameError => e
137
- raise NameError, "Unknown class: #{name} (#{e})"
138
- end
139
- end
140
-
141
- # Handler for Logic classes (new in Otto Framework Enhancement)
142
- # Supports the OneTime Secret Logic class pattern
143
- class LogicClassHandler < BaseHandler
144
- def call(env, extra_params = {})
145
- req = Rack::Request.new(env)
146
- res = Rack::Response.new
147
-
148
- begin
149
- # Get authentication context if available
150
- auth_result = env['otto.auth_result']
151
-
152
- # Initialize Logic class with standard parameters
153
- # Logic classes expect: session, user, params, locale
154
- logic_params = req.params.merge(extra_params)
155
- locale = env['otto.locale'] || 'en'
156
-
157
- logic = if target_class.instance_method(:initialize).arity == 4
158
- # Standard Logic class constructor
159
- target_class.new(
160
- auth_result&.session,
161
- auth_result&.user,
162
- logic_params,
163
- locale
164
- )
165
- else
166
- # Fallback for custom constructors
167
- target_class.new(req, res)
168
- end
169
-
170
- # Execute standard Logic class lifecycle
171
- if logic.respond_to?(:raise_concerns)
172
- logic.raise_concerns
173
- end
174
-
175
- result = if logic.respond_to?(:process)
176
- logic.process
177
- else
178
- logic.call || logic
179
- end
180
-
181
- # Handle response with Logic instance context
182
- handle_response(result, res, {
183
- logic_instance: logic,
184
- request: req,
185
- status_code: logic.respond_to?(:status_code) ? logic.status_code : nil
186
- })
187
-
188
- rescue => e
189
- # Error handling - return 500 with proper headers like main Otto error handler
190
- error_id = SecureRandom.hex(8)
191
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
192
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
193
-
194
- res.status = 500
195
- res.headers['content-type'] = 'text/plain'
196
-
197
- if Otto.env?(:dev, :development)
198
- res.write "Server error (ID: #{error_id}). Check logs for details."
199
- else
200
- res.write "An error occurred. Please try again later."
201
- end
202
-
203
- # Add security headers if available
204
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
205
- otto_instance.security_config.security_headers.each do |header, value|
206
- res.headers[header] = value
207
- end
208
- end
209
- end
210
-
211
- res.finish
212
- end
213
- end
214
-
215
- # Handler for instance methods (existing Otto pattern)
216
- # Maintains backward compatibility for Controller#action patterns
217
- class InstanceMethodHandler < BaseHandler
218
- def call(env, extra_params = {})
219
- req = Rack::Request.new(env)
220
- res = Rack::Response.new
221
-
222
- begin
223
- # Apply the same extensions and processing as original Route#call
224
- setup_request_response(req, res, env, extra_params)
225
-
226
- # Create instance and call method (existing Otto behavior)
227
- instance = target_class.new(req, res)
228
- result = instance.send(route_definition.method_name)
229
-
230
- # Only handle response if response_type is not default
231
- if route_definition.response_type != 'default'
232
- handle_response(result, res, {
233
- instance: instance,
234
- request: req
235
- })
236
- end
237
-
238
- rescue => e
239
- # Error handling - return 500 with proper headers like main Otto error handler
240
- error_id = SecureRandom.hex(8)
241
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
242
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
243
-
244
- res.status = 500
245
- res.headers['content-type'] = 'text/plain'
246
-
247
- if Otto.env?(:dev, :development)
248
- res.write "Server error (ID: #{error_id}). Check logs for details."
249
- else
250
- res.write "An error occurred. Please try again later."
251
- end
252
-
253
- # Add security headers if available
254
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
255
- otto_instance.security_config.security_headers.each do |header, value|
256
- res.headers[header] = value
257
- end
258
- end
259
- end
260
-
261
- finalize_response(res)
262
- end
263
- end
264
-
265
- # Handler for class methods (existing Otto pattern)
266
- # Maintains backward compatibility for Controller.action patterns
267
- class ClassMethodHandler < BaseHandler
268
- def call(env, extra_params = {})
269
- req = Rack::Request.new(env)
270
- res = Rack::Response.new
271
-
272
- begin
273
- # Apply the same extensions and processing as original Route#call
274
- setup_request_response(req, res, env, extra_params)
275
-
276
- # Call class method directly (existing Otto behavior)
277
- result = target_class.send(route_definition.method_name, req, res)
278
-
279
- # Only handle response if response_type is not default
280
- if route_definition.response_type != 'default'
281
- handle_response(result, res, {
282
- class: target_class,
283
- request: req
284
- })
285
- end
286
-
287
- rescue => e
288
- # Error handling - return 500 with proper headers like main Otto error handler
289
- error_id = SecureRandom.hex(8)
290
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
291
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
292
-
293
- res.status = 500
294
-
295
- # Content negotiation for error response
296
- accept_header = env['HTTP_ACCEPT'].to_s
297
- if accept_header.include?('application/json')
298
- res.headers['content-type'] = 'application/json'
299
- error_data = if Otto.env?(:dev, :development)
300
- {
301
- error: 'Internal Server Error',
302
- message: 'Server error occurred. Check logs for details.',
303
- error_id: error_id,
304
- }
305
- else
306
- {
307
- error: 'Internal Server Error',
308
- message: 'An error occurred. Please try again later.',
309
- }
310
- end
311
- res.write JSON.generate(error_data)
312
- else
313
- res.headers['content-type'] = 'text/plain'
314
- if Otto.env?(:dev, :development)
315
- res.write "Server error (ID: #{error_id}). Check logs for details."
316
- else
317
- res.write "An error occurred. Please try again later."
318
- end
319
- end
320
-
321
- # Add security headers if available
322
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
323
- otto_instance.security_config.security_headers.each do |header, value|
324
- res.headers[header] = value
325
- end
326
- end
327
- end
328
-
329
- finalize_response(res)
330
- end
331
- end
332
-
333
- # Custom handler for lambda/proc definitions (future extension)
334
- class LambdaHandler < BaseHandler
335
- def call(env, extra_params = {})
336
- req = Rack::Request.new(env)
337
- res = Rack::Response.new
338
-
339
- begin
340
- # Security: Lambda handlers require pre-configured procs from Otto instance
341
- # This prevents code injection via eval and maintains security
342
- handler_name = route_definition.klass_name
343
- lambda_registry = otto_instance&.config&.dig(:lambda_handlers) || {}
344
-
345
- lambda_proc = lambda_registry[handler_name]
346
- unless lambda_proc.respond_to?(:call)
347
- raise ArgumentError, "Lambda handler '#{handler_name}' not found in registry or not callable"
348
- end
349
-
350
- result = lambda_proc.call(req, res, extra_params)
351
-
352
- handle_response(result, res, {
353
- lambda: lambda_proc,
354
- request: req
355
- })
356
-
357
- rescue => e
358
- error_id = SecureRandom.hex(8)
359
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
360
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
361
-
362
- res.status = 500
363
- res.headers['content-type'] = 'text/plain'
364
-
365
- if Otto.env?(:dev, :development)
366
- res.write "Lambda handler error (ID: #{error_id}). Check logs for details."
367
- else
368
- res.write "An error occurred. Please try again later."
369
- end
370
-
371
- # Add security headers if available
372
- if otto_instance&.respond_to?(:security_config) && otto_instance.security_config
373
- otto_instance.security_config.security_headers.each do |header, value|
374
- res.headers[header] = value
375
- end
376
- end
377
- end
378
-
379
- res.finish
380
- end
381
- 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'
382
16
  end
383
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
@@ -0,0 +1,47 @@
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
+ # Permission-based authentication strategy
10
+ class PermissionStrategy < AuthStrategy
11
+ def initialize(required_permissions, session_key: 'user_permissions')
12
+ @required_permissions = Array(required_permissions)
13
+ @session_key = session_key
14
+ end
15
+
16
+ def authenticate(env, requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_permissions = session[@session_key] || []
21
+ user_permissions = Array(user_permissions)
22
+
23
+ # Create user data from session
24
+ user_data = { user_permissions: user_permissions, session: session }
25
+
26
+ # Extract permission from requirement (e.g., "permission:write" -> "write")
27
+ required_permission = requirement.split(':', 2).last
28
+
29
+ if user_permissions.include?(required_permission)
30
+ success(user: user_data, user_permissions: user_permissions, required_permission: required_permission)
31
+ else
32
+ failure("Insufficient privileges - requires permission: #{required_permission}")
33
+ end
34
+ end
35
+
36
+ def user_context(env)
37
+ session = env['rack.session']
38
+ return {} unless session
39
+
40
+ user_permissions = session[@session_key] || []
41
+ { user_permissions: Array(user_permissions) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end