otto 2.0.0.pre7 → 2.0.0.pre9

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +5 -8
  6. data/CHANGELOG.rst +87 -2
  7. data/Gemfile.lock +6 -6
  8. data/README.md +20 -0
  9. data/docs/.gitignore +2 -0
  10. data/docs/modern-authentication-authorization-landscape.md +558 -0
  11. data/docs/multi-strategy-authentication-design.md +1401 -0
  12. data/lib/otto/core/error_handler.rb +19 -8
  13. data/lib/otto/core/freezable.rb +0 -2
  14. data/lib/otto/core/middleware_stack.rb +12 -8
  15. data/lib/otto/core/router.rb +25 -31
  16. data/lib/otto/errors.rb +92 -0
  17. data/lib/otto/mcp/rate_limiting.rb +6 -2
  18. data/lib/otto/mcp/schema_validation.rb +1 -1
  19. data/lib/otto/response_handlers/json.rb +1 -3
  20. data/lib/otto/response_handlers/view.rb +1 -1
  21. data/lib/otto/route_handlers/base.rb +86 -1
  22. data/lib/otto/route_handlers/class_method.rb +17 -68
  23. data/lib/otto/route_handlers/instance_method.rb +18 -58
  24. data/lib/otto/route_handlers/logic_class.rb +95 -92
  25. data/lib/otto/security/authentication/auth_strategy.rb +2 -2
  26. data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
  27. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
  28. data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
  29. data/lib/otto/security/authentication/route_auth_wrapper.rb +167 -329
  30. data/lib/otto/security/authentication/strategy_result.rb +9 -9
  31. data/lib/otto/security/authorization_error.rb +1 -1
  32. data/lib/otto/security/config.rb +3 -3
  33. data/lib/otto/security/rate_limiter.rb +7 -3
  34. data/lib/otto/version.rb +1 -1
  35. data/lib/otto.rb +47 -3
  36. metadata +7 -3
  37. data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +0 -15
  38. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +0 -37
@@ -1,28 +1,27 @@
1
- # lib/otto/security/authentication/route_auth_wrapper.rb
2
- #
3
1
  # frozen_string_literal: true
4
2
 
3
+ require_relative 'route_auth_wrapper/strategy_resolver'
4
+ require_relative 'route_auth_wrapper/response_builder'
5
+ require_relative 'route_auth_wrapper/role_authorization'
6
+
5
7
  class Otto
6
8
  module Security
7
9
  module Authentication
8
- # Wraps route handlers to enforce authentication requirements
10
+ # Wraps route handlers with authentication and authorization
9
11
  #
10
- # This wrapper executes authentication strategies AFTER routing but BEFORE
11
- # route handler execution. This solves the architectural issue where
12
- # middleware-based authentication runs before routing (so can't access route info).
12
+ # This is the main orchestrator that:
13
+ # - Sets anonymous StrategyResult for unauthenticated routes
14
+ # - Enforces authentication for protected routes
15
+ # - Supports multi-strategy with OR logic (first success wins)
16
+ # - Performs Layer 1 (route-level) role authorization
13
17
  #
14
- # Flow:
15
- # 1. Route matched (route_definition available)
16
- # 2. RouteAuthWrapper#call invoked
17
- # 3. Execute auth strategy based on route's auth_requirement
18
- # 4. Set env['otto.strategy_result']
19
- # 5. If auth fails, return 401 or redirect
20
- # 6. If auth succeeds, call wrapped handler
18
+ # @example Basic usage
19
+ # wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
20
+ # response = wrapper.call(env)
21
21
  #
22
- # @example
23
- # handler = InstanceMethodHandler.new(route_def, otto)
24
- # wrapped = RouteAuthWrapper.new(handler, route_def, auth_config)
25
- # wrapped.call(env, extra_params)
22
+ # @see RouteAuthWrapper::StrategyResolver for strategy lookup
23
+ # @see RouteAuthWrapper::ResponseBuilder for error responses
24
+ # @see RouteAuthWrapper::RoleAuthorization for role checking
26
25
  #
27
26
  class RouteAuthWrapper
28
27
  attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
@@ -30,17 +29,17 @@ class Otto
30
29
  def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
31
30
  @wrapped_handler = wrapped_handler
32
31
  @route_definition = route_definition
33
- @auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
32
+ @auth_config = auth_config
34
33
  @security_config = security_config
35
- @strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
34
+
35
+ # Initialize extracted components
36
+ @strategy_resolver = RouteAuthWrapperComponents::StrategyResolver.new(auth_config)
37
+ @response_builder = RouteAuthWrapperComponents::ResponseBuilder.new(route_definition, auth_config, security_config)
38
+ @role_authorizer = RouteAuthWrapperComponents::RoleAuthorization.new(route_definition)
36
39
  end
37
40
 
38
41
  # Execute authentication then call wrapped handler
39
42
  #
40
- # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
- # For routes WITH auth requirement: Enforces authentication
42
- # Supports multi-strategy with OR logic: auth=session,apikey,oauth
43
- #
44
43
  # @param env [Hash] Rack environment
45
44
  # @param extra_params [Hash] Additional parameters
46
45
  # @return [Array] Rack response array
@@ -48,363 +47,202 @@ class Otto
48
47
  auth_requirements = route_definition.auth_requirements
49
48
 
50
49
  # Routes without auth requirement get anonymous StrategyResult
51
- if auth_requirements.empty?
52
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
53
- metadata = { ip: env['REMOTE_ADDR'] }
54
- metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
55
-
56
- result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
57
- env['otto.strategy_result'] = result
58
- return wrapped_handler.call(env, extra_params)
59
- end
60
-
61
- # Routes WITH auth requirements: Try each strategy in order (first success wins)
50
+ return handle_anonymous_route(env, extra_params) if auth_requirements.empty?
62
51
 
63
52
  # Validate all strategies exist before executing any (fail-fast)
53
+ validation_error = validate_strategies(auth_requirements, env)
54
+ return validation_error if validation_error
55
+
56
+ # Try each strategy in order (first success wins)
57
+ authenticate_and_authorize(env, extra_params, auth_requirements)
58
+ end
59
+
60
+ private
61
+
62
+ # Handle routes without authentication requirements
63
+ def handle_anonymous_route(env, extra_params)
64
+ metadata = build_anonymous_metadata(env)
65
+ result = StrategyResult.anonymous(metadata: metadata, strategy_name: 'anonymous')
66
+ env['otto.strategy_result'] = result
67
+ wrapped_handler.call(env, extra_params)
68
+ end
69
+
70
+ # Validate all strategies exist before executing
71
+ #
72
+ # @return [Array, nil] Error response if validation fails, nil otherwise
73
+ def validate_strategies(auth_requirements, env)
64
74
  auth_requirements.each do |requirement|
65
- strategy, _strategy_name = get_strategy(requirement)
66
- unless strategy
67
- error_msg = "Authentication strategy not configured: '#{requirement}'"
68
- Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
69
- return unauthorized_response(env, error_msg)
70
- end
75
+ strategy, _name = @strategy_resolver.resolve(requirement)
76
+ next if strategy
77
+
78
+ error_msg = "Authentication strategy not configured: '#{requirement}'"
79
+ Otto.logger.error "[RouteAuthWrapper] #{error_msg}"
80
+ return @response_builder.unauthorized(env, error_msg)
71
81
  end
82
+ nil
83
+ end
72
84
 
73
- last_failure = nil
85
+ # Main authentication and authorization flow
86
+ def authenticate_and_authorize(env, extra_params, auth_requirements)
74
87
  failed_strategies = []
75
88
  total_start_time = Otto::Utils.now_in_μs
76
89
 
77
90
  auth_requirements.each do |requirement|
78
- strategy, strategy_name = get_strategy(requirement)
79
-
80
- # Log strategy execution start
81
- Otto.structured_log(:debug, "Auth strategy executing",
82
- Otto::LoggingHelpers.request_context(env).merge(
83
- strategy: strategy_name,
84
- requirement: requirement,
85
- strategy_position: auth_requirements.index(requirement) + 1,
86
- total_strategies: auth_requirements.size
87
- )
88
- )
91
+ strategy, strategy_name = @strategy_resolver.resolve(requirement)
92
+
93
+ log_strategy_start(env, strategy_name, requirement, auth_requirements)
89
94
 
90
95
  # Execute the strategy
91
96
  start_time = Otto::Utils.now_in_μs
92
97
  result = strategy.authenticate(env, requirement)
93
98
  duration = Otto::Utils.now_in_μs - start_time
94
99
 
95
- # Inject strategy_name into result (Data.define objects are immutable, use #with for updates)
96
- if result.is_a?(StrategyResult)
97
- result = result.with(strategy_name: strategy_name)
98
- end
100
+ # Inject strategy_name into result
101
+ result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
99
102
 
100
- # Handle authentication success (both authenticated and anonymous results) - return immediately
103
+ # Handle authentication success
101
104
  if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
102
- total_duration = Otto::Utils.now_in_μs - total_start_time
103
-
104
- # Log authentication success
105
- Otto.structured_log(:info, "Auth strategy result",
106
- Otto::LoggingHelpers.request_context(env).merge(
107
- strategy: strategy_name,
108
- success: true,
109
- user_id: result.user_id,
110
- duration: duration,
111
- total_duration: total_duration,
112
- strategies_attempted: failed_strategies.size + 1
113
- )
114
- )
115
-
116
- # Set environment variables for controllers/logic on success
117
- env['otto.strategy_result'] = result
118
-
119
- # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
120
- # We must ensure env['rack.session'] and strategy_result.session reference
121
- # the SAME object so that:
122
- # 1. Logic classes write to strategy_result.session
123
- # 2. Rack's session middleware persists env['rack.session']
124
- # 3. Changes from (1) are included in (2)
125
- #
126
- # Using merge! instead would break this - the objects must be identical.
127
- env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
128
-
129
- # Layer 1 Authorization: Check role requirements (route-level)
130
- role_requirements = route_definition.role_requirements
131
- unless role_requirements.empty?
132
- user_roles = extract_user_roles(result)
133
-
134
- # OR logic: user needs ANY of the required roles
135
- unless (user_roles & role_requirements).any?
136
- Otto.structured_log(:warn, "Role authorization failed",
137
- Otto::LoggingHelpers.request_context(env).merge(
138
- required_roles: role_requirements,
139
- user_roles: user_roles,
140
- user_id: result.user_id
141
- )
142
- )
143
-
144
- return forbidden_response(env,
145
- "Access denied: requires one of roles: #{role_requirements.join(', ')}")
146
- end
147
-
148
- Otto.structured_log(:debug, "Role authorization succeeded",
149
- Otto::LoggingHelpers.request_context(env).merge(
150
- required_roles: role_requirements,
151
- user_roles: user_roles,
152
- matched_roles: user_roles & role_requirements
153
- )
154
- )
155
- end
156
-
157
- # Authentication and authorization succeeded - call wrapped handler
158
- return wrapped_handler.call(env, extra_params)
105
+ return handle_auth_success(env, extra_params, result, strategy_name,
106
+ duration, total_start_time, failed_strategies)
159
107
  end
160
108
 
161
109
  # Handle authentication failure - continue to next strategy
162
- if result.is_a?(AuthFailure)
163
- # Log authentication failure
164
- Otto.structured_log(:info, "Auth strategy result",
165
- Otto::LoggingHelpers.request_context(env).merge(
166
- strategy: strategy_name,
167
- success: false,
168
- failure_reason: result.failure_reason,
169
- duration: duration,
170
- remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
171
- )
172
- )
173
-
174
- failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
175
- last_failure = result
176
- end
177
- end
178
-
179
- # All strategies failed - return 401
180
- total_duration = Otto::Utils.now_in_μs - total_start_time
181
-
182
- # Log comprehensive failure
183
- Otto.structured_log(:warn, "All auth strategies failed",
184
- Otto::LoggingHelpers.request_context(env).merge(
185
- strategies_attempted: failed_strategies.map { |f| f[:strategy] },
186
- total_duration: total_duration,
187
- failure_count: failed_strategies.size
188
- )
189
- )
190
-
191
- # Create anonymous result with comprehensive failure info
192
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
193
- metadata = {
194
- ip: env['REMOTE_ADDR'],
195
- auth_failure: "All authentication strategies failed",
196
- attempted_strategies: failed_strategies.map { |f| f[:strategy] },
197
- failure_reasons: failed_strategies.map { |f| f[:reason] }
198
- }
199
- metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
110
+ next unless result.is_a?(AuthFailure)
200
111
 
201
- # Use 'multi-strategy-failure' only for actual multi-strategy failures
202
- # For single-strategy failures, use the actual strategy name
203
- failure_strategy_name = if auth_requirements.size > 1
204
- 'multi-strategy-failure'
205
- elsif failed_strategies.any?
206
- failed_strategies.first[:strategy]
207
- else
208
- auth_requirements.first
112
+ log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
113
+ failed_strategies << { strategy: strategy_name, reason: result.failure_reason }
209
114
  end
210
115
 
211
- env['otto.strategy_result'] = StrategyResult.anonymous(
212
- metadata: metadata,
213
- strategy_name: failure_strategy_name
214
- )
215
-
216
- auth_failure_response(env, last_failure || AuthFailure.new(
217
- failure_reason: "Authentication required",
218
- auth_method: failure_strategy_name
219
- ))
116
+ # All strategies failed
117
+ handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
220
118
  end
221
119
 
222
- private
120
+ # Handle successful authentication
121
+ def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
122
+ total_duration = Otto::Utils.now_in_μs - total_start_time
223
123
 
224
- # Get strategy from auth_config hash with pattern matching
225
- #
226
- # Supports:
227
- # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
228
- # - Prefix match: 'custom:value' → looks up 'custom' strategy
229
- #
230
- # Results are cached to avoid repeated lookups for the same requirement.
231
- #
232
- # NOTE: Role-based authorization should use route option `role=admin` instead of `auth=role:admin`
233
- # to properly separate authentication from authorization concerns.
234
- #
235
- # @param requirement [String] Auth requirement from route
236
- # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
237
- def get_strategy(requirement)
238
- return [nil, nil] unless auth_config && auth_config[:auth_strategies]
239
-
240
- # Check cache first (cache stores [strategy, name] tuples)
241
- return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
242
-
243
- # Try exact match first - this has highest priority
244
- strategy = auth_config[:auth_strategies][requirement]
245
- if strategy
246
- result = [strategy, requirement]
247
- @strategy_cache[requirement] = result
248
- return result
249
- end
124
+ log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
250
125
 
251
- # For colon-separated requirements like "custom:value", try prefix match
252
- if requirement.include?(':')
253
- prefix = requirement.split(':', 2).first
126
+ # Set environment variables for controllers/logic
127
+ env['otto.strategy_result'] = result
254
128
 
255
- # Check if we have a strategy registered for the prefix
256
- prefix_strategy = auth_config[:auth_strategies][prefix]
257
- if prefix_strategy
258
- result = [prefix_strategy, prefix]
259
- @strategy_cache[requirement] = result
260
- return result
261
- end
129
+ # SESSION PERSISTENCE: Ensure env['rack.session'] and strategy_result.session
130
+ # reference the SAME object for proper session persistence
131
+ env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
132
+
133
+ # Layer 1 Authorization: Check role requirements
134
+ auth_check = @role_authorizer.check(result, env)
135
+ unless auth_check == true
136
+ return @response_builder.forbidden(env,
137
+ "Access denied: requires one of roles: #{auth_check[:required].join(', ')}")
262
138
  end
263
139
 
264
- # Cache nil results too to avoid repeated failed lookups
265
- @strategy_cache[requirement] = [nil, nil]
266
- [nil, nil]
140
+ # Authentication and authorization succeeded
141
+ wrapped_handler.call(env, extra_params)
267
142
  end
268
143
 
269
- # Generate 401 response for authentication failure
270
- #
271
- # @param env [Hash] Rack environment
272
- # @param result [AuthFailure] Failure result from strategy
273
- # @return [Array] Rack response array
274
- def auth_failure_response(env, result)
275
- # Check if request wants JSON
276
- accept_header = env['HTTP_ACCEPT'] || ''
277
- wants_json = accept_header.include?('application/json')
144
+ # Handle case when all authentication strategies fail
145
+ def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
146
+ total_duration = Otto::Utils.now_in_μs - total_start_time
278
147
 
279
- if wants_json
280
- json_auth_error(result)
281
- else
282
- html_auth_error(result)
283
- end
284
- end
148
+ log_all_failed(env, failed_strategies, total_duration)
285
149
 
286
- # Generate JSON 401 response
287
- #
288
- # @param result [AuthFailure] Failure result
289
- # @return [Array] Rack response array
290
- def json_auth_error(result)
291
- body = {
292
- error: 'Authentication Required',
293
- message: result.failure_reason || 'Not authenticated',
294
- timestamp: Time.now.to_i
295
- }.to_json
296
-
297
- headers = {
298
- 'content-type' => 'application/json',
299
- 'content-length' => body.bytesize.to_s
300
- }
150
+ # Create anonymous result with failure info
151
+ metadata = build_failure_metadata(env, failed_strategies)
152
+ failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
301
153
 
302
- # Add security headers if available
303
- merge_security_headers!(headers)
154
+ env['otto.strategy_result'] = StrategyResult.anonymous(
155
+ metadata: metadata,
156
+ strategy_name: failure_strategy_name
157
+ )
304
158
 
305
- [401, headers, [body]]
159
+ last_failure = if failed_strategies.any?
160
+ AuthFailure.new(
161
+ failure_reason: failed_strategies.last[:reason],
162
+ auth_method: failed_strategies.last[:strategy]
163
+ )
164
+ else
165
+ AuthFailure.new(
166
+ failure_reason: 'Authentication required',
167
+ auth_method: auth_requirements.first
168
+ )
169
+ end
170
+
171
+ @response_builder.auth_failure(env, last_failure)
306
172
  end
307
173
 
308
- # Generate HTML 401 response or redirect
309
- #
310
- # @param result [AuthFailure] Failure result
311
- # @return [Array] Rack response array
312
- def html_auth_error(result)
313
- # For HTML requests, redirect to login
314
- login_path = auth_config[:login_path] || '/signin'
315
-
316
- headers = { 'location' => login_path }
317
-
318
- # Add security headers if available
319
- merge_security_headers!(headers)
320
-
321
- [302, headers, ["Redirecting to #{login_path}"]]
174
+ # Build metadata for anonymous routes
175
+ def build_anonymous_metadata(env)
176
+ metadata = { ip: env['REMOTE_ADDR'] }
177
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
178
+ metadata
322
179
  end
323
180
 
324
- # Generate generic unauthorized response
325
- #
326
- # @param env [Hash] Rack environment
327
- # @param message [String] Error message
328
- # @return [Array] Rack response array
329
- def unauthorized_response(env, message)
330
- accept_header = env['HTTP_ACCEPT'] || ''
331
- wants_json = accept_header.include?('application/json')
332
-
333
- if wants_json
334
- body = { error: message }.to_json
335
- headers = {
336
- 'content-type' => 'application/json',
337
- 'content-length' => body.bytesize.to_s
338
- }
339
- merge_security_headers!(headers)
340
- [401, headers, [body]]
341
- else
342
- headers = { 'content-type' => 'text/plain' }
343
- merge_security_headers!(headers)
344
- [401, headers, [message]]
345
- end
181
+ # Build metadata for failed authentication
182
+ def build_failure_metadata(env, failed_strategies)
183
+ metadata = {
184
+ ip: env['REMOTE_ADDR'],
185
+ auth_failure: 'All authentication strategies failed',
186
+ attempted_strategies: failed_strategies.map { |f| f[:strategy] },
187
+ failure_reasons: failed_strategies.map { |f| f[:reason] },
188
+ }
189
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
190
+ metadata
346
191
  end
347
192
 
348
- # Generate 403 Forbidden response for role authorization failure
349
- #
350
- # @param env [Hash] Rack environment
351
- # @param message [String] Error message
352
- # @return [Array] Rack response array
353
- def forbidden_response(env, message)
354
- accept_header = env['HTTP_ACCEPT'] || ''
355
- wants_json = accept_header.include?('application/json')
356
-
357
- if wants_json
358
- body = { error: 'Forbidden', message: message }.to_json
359
- headers = {
360
- 'content-type' => 'application/json',
361
- 'content-length' => body.bytesize.to_s
362
- }
363
- merge_security_headers!(headers)
364
- [403, headers, [body]]
193
+ # Determine strategy name for failure response
194
+ def determine_failure_strategy_name(auth_requirements, failed_strategies)
195
+ if auth_requirements.size > 1
196
+ 'multi-strategy-failure'
197
+ elsif failed_strategies.any?
198
+ failed_strategies.first[:strategy]
365
199
  else
366
- headers = { 'content-type' => 'text/plain' }
367
- merge_security_headers!(headers)
368
- [403, headers, [message]]
200
+ auth_requirements.first
369
201
  end
370
202
  end
371
203
 
372
- # Extract user roles from authentication result
373
- #
374
- # Supports multiple role sources in order of precedence:
375
- # 1. result.user_roles (Array)
376
- # 2. result.user[:roles] (Array)
377
- # 3. result.user['roles'] (Array)
378
- # 4. result.metadata[:user_roles] (Array)
379
- #
380
- # @param result [StrategyResult] Authentication result
381
- # @return [Array<String>] Array of role strings
382
- def extract_user_roles(result)
383
- # Try direct user_roles accessor (e.g., from RoleStrategy)
384
- return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
385
-
386
- # Try user hash/object with roles
387
- if result.user
388
- roles = result.user[:roles] || result.user['roles']
389
- return Array(roles) if roles
390
- end
204
+ # Logging helpers
391
205
 
392
- # Try metadata
393
- if result.metadata && result.metadata[:user_roles]
394
- return Array(result.metadata[:user_roles])
395
- end
206
+ def log_strategy_start(env, strategy_name, requirement, auth_requirements)
207
+ Otto.structured_log(:debug, 'Auth strategy executing',
208
+ Otto::LoggingHelpers.request_context(env).merge(
209
+ strategy: strategy_name,
210
+ requirement: requirement,
211
+ strategy_position: auth_requirements.index(requirement) + 1,
212
+ total_strategies: auth_requirements.size
213
+ ))
214
+ end
396
215
 
397
- # No roles found
398
- []
216
+ def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
217
+ Otto.structured_log(:info, 'Auth strategy result',
218
+ Otto::LoggingHelpers.request_context(env).merge(
219
+ strategy: strategy_name,
220
+ success: true,
221
+ user_id: result.user_id,
222
+ duration: duration,
223
+ total_duration: total_duration,
224
+ strategies_attempted: failed_strategies.size + 1
225
+ ))
399
226
  end
400
227
 
401
- # Merge security headers into response headers
402
- #
403
- # @param headers [Hash] Response headers hash to merge into
404
- def merge_security_headers!(headers)
405
- return unless security_config
228
+ def log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
229
+ Otto.structured_log(:info, 'Auth strategy result',
230
+ Otto::LoggingHelpers.request_context(env).merge(
231
+ strategy: strategy_name,
232
+ success: false,
233
+ failure_reason: result.failure_reason,
234
+ duration: duration,
235
+ remaining_strategies: auth_requirements.size - auth_requirements.index(requirement) - 1
236
+ ))
237
+ end
406
238
 
407
- headers.merge!(security_config.security_headers)
239
+ def log_all_failed(env, failed_strategies, total_duration)
240
+ Otto.structured_log(:warn, 'All auth strategies failed',
241
+ Otto::LoggingHelpers.request_context(env).merge(
242
+ strategies_attempted: failed_strategies.map { |f| f[:strategy] },
243
+ total_duration: total_duration,
244
+ failure_count: failed_strategies.size
245
+ ))
408
246
  end
409
247
  end
410
248
  end
@@ -320,16 +320,16 @@ class Otto
320
320
  # @return [Hash] Hash representation of the context
321
321
  def to_h
322
322
  {
323
- session: session,
324
- user: user,
325
- auth_method: auth_method,
326
- metadata: metadata,
327
- authenticated: authenticated?,
323
+ session: session,
324
+ user: user,
325
+ auth_method: auth_method,
326
+ metadata: metadata,
327
+ authenticated: authenticated?,
328
328
  auth_attempt_succeeded: auth_attempt_succeeded?,
329
- user_id: user_id,
330
- user_name: user_name,
331
- roles: roles,
332
- permissions: permissions
329
+ user_id: user_id,
330
+ user_name: user_name,
331
+ roles: roles,
332
+ permissions: permissions,
333
333
  }
334
334
  end
335
335
  end
@@ -40,7 +40,7 @@ class Otto
40
40
  # end
41
41
  # end
42
42
  #
43
- class AuthorizationError < StandardError
43
+ class AuthorizationError < Otto::ForbiddenError
44
44
  # Optional additional context for logging/debugging
45
45
  attr_reader :resource, :action, :user_id
46
46
 
@@ -436,12 +436,12 @@ class Otto
436
436
  end
437
437
 
438
438
  # Raised when a request exceeds the configured size limit
439
- class RequestTooLargeError < StandardError; end
439
+ class RequestTooLargeError < Otto::PayloadTooLargeError; end
440
440
 
441
441
  # Raised when CSRF token validation fails
442
- class CSRFError < StandardError; end
442
+ class CSRFError < Otto::ForbiddenError; end
443
443
 
444
444
  # Raised when input validation fails (XSS, SQL injection, etc.)
445
- class ValidationError < StandardError; end
445
+ class ValidationError < Otto::BadRequestError; end
446
446
  end
447
447
  end
@@ -55,9 +55,13 @@ class Otto
55
55
  'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
56
56
  }
57
57
 
58
- # Check if request expects JSON
59
- accept_header = request.env['HTTP_ACCEPT'].to_s
60
- if accept_header.include?('application/json')
58
+ # Content negotiation for rate limit response
59
+ # Route's response_type takes precedence over Accept header
60
+ route_def = request.env['otto.route_definition']
61
+ wants_json = (route_def&.response_type == 'json') ||
62
+ request.env['HTTP_ACCEPT'].to_s.include?('application/json')
63
+
64
+ if wants_json
61
65
  error_response = {
62
66
  error: 'Rate limit exceeded',
63
67
  message: 'Too many requests',
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre7'
6
+ VERSION = '2.0.0.pre9'
7
7
  end