otto 2.0.0.pre1 → 2.0.0.pre3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -3
  3. data/.github/workflows/claude-code-review.yml +30 -14
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.rubocop.yml +4 -1
  6. data/CHANGELOG.rst +54 -6
  7. data/CLAUDE.md +537 -0
  8. data/Gemfile +3 -2
  9. data/Gemfile.lock +34 -26
  10. data/benchmark_middleware_wrap.rb +163 -0
  11. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  12. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  13. data/docs/.gitignore +2 -0
  14. data/docs/ipaddr-encoding-quirk.md +34 -0
  15. data/docs/migrating/v2.0.0-pre2.md +338 -0
  16. data/examples/authentication_strategies/config.ru +0 -1
  17. data/lib/otto/core/configuration.rb +91 -41
  18. data/lib/otto/core/freezable.rb +93 -0
  19. data/lib/otto/core/middleware_stack.rb +103 -16
  20. data/lib/otto/core/router.rb +8 -7
  21. data/lib/otto/core.rb +8 -0
  22. data/lib/otto/env_keys.rb +118 -0
  23. data/lib/otto/helpers/base.rb +2 -21
  24. data/lib/otto/helpers/request.rb +80 -2
  25. data/lib/otto/helpers/response.rb +25 -3
  26. data/lib/otto/helpers.rb +4 -0
  27. data/lib/otto/locale/config.rb +56 -0
  28. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
  29. data/lib/otto/mcp/server.rb +26 -13
  30. data/lib/otto/mcp.rb +3 -0
  31. data/lib/otto/privacy/config.rb +199 -0
  32. data/lib/otto/privacy/geo_resolver.rb +115 -0
  33. data/lib/otto/privacy/ip_privacy.rb +175 -0
  34. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  35. data/lib/otto/privacy.rb +29 -0
  36. data/lib/otto/response_handlers/json.rb +6 -0
  37. data/lib/otto/route.rb +44 -48
  38. data/lib/otto/route_handlers/base.rb +1 -2
  39. data/lib/otto/route_handlers/factory.rb +24 -9
  40. data/lib/otto/route_handlers/logic_class.rb +2 -2
  41. data/lib/otto/security/authentication/auth_failure.rb +44 -0
  42. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  43. data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
  44. data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
  45. data/lib/otto/security/authentication/strategy_result.rb +129 -15
  46. data/lib/otto/security/authentication.rb +5 -6
  47. data/lib/otto/security/config.rb +51 -18
  48. data/lib/otto/security/configurator.rb +2 -15
  49. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  50. data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
  51. data/lib/otto/security.rb +9 -0
  52. data/lib/otto/version.rb +1 -1
  53. data/lib/otto.rb +183 -89
  54. data/otto.gemspec +5 -0
  55. metadata +83 -8
  56. data/changelog.d/20250911_235619_delano_next.rst +0 -28
  57. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
  58. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
  59. data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
  60. data/lib/otto/security/authentication/failure_result.rb +0 -36
data/lib/otto/route.rb CHANGED
@@ -45,10 +45,10 @@ class Otto
45
45
  # "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
46
46
  # @raise [ArgumentError] if definition format is invalid or class name is unsafe
47
47
  def initialize(verb, path, definition)
48
- @pattern, @keys = *compile(path)
48
+ pattern, keys = *compile(path)
49
49
 
50
50
  # Create immutable route definition
51
- @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: @pattern, keys: @keys)
51
+ @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
52
52
 
53
53
  # Resolve the class
54
54
  @klass = safe_const_get(@route_definition.klass_name)
@@ -87,52 +87,6 @@ class Otto
87
87
  @route_definition.options
88
88
  end
89
89
 
90
- private
91
-
92
- # Safely resolve a class name using Object.const_get with security validations
93
- # This replaces the previous eval() usage to prevent code injection attacks.
94
- #
95
- # Security features:
96
- # - Validates class name format (must start with capital letter)
97
- # - Prevents access to dangerous system classes
98
- # - Blocks relative class references (starting with ::)
99
- # - Provides clear error messages for debugging
100
- #
101
- # @param class_name [String] The class name to resolve
102
- # @return [Class] The resolved class
103
- # @raise [ArgumentError] if class name is invalid, forbidden, or not found
104
- def safe_const_get(class_name)
105
- # Validate class name format
106
- unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
107
- raise ArgumentError, "Invalid class name format: #{class_name}"
108
- end
109
-
110
- # Prevent dangerous class names
111
- forbidden_classes = %w[
112
- Kernel Module Class Object BasicObject
113
- File Dir IO Process System
114
- Binding Proc Method UnboundMethod
115
- Thread ThreadGroup Fiber
116
- ObjectSpace GC
117
- ]
118
-
119
- if forbidden_classes.include?(class_name) || class_name.start_with?('::')
120
- raise ArgumentError, "Forbidden class name: #{class_name}"
121
- end
122
-
123
- begin
124
- Object.const_get(class_name)
125
- rescue NameError => e
126
- raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
127
- end
128
- end
129
-
130
- public
131
-
132
- def pattern_regexp
133
- Regexp.new(@path.gsub('/*', '/.+'))
134
- end
135
-
136
90
  # Execute the route by calling the associated class method
137
91
  #
138
92
  # This method handles the complete request/response cycle with built-in security:
@@ -218,6 +172,48 @@ class Otto
218
172
 
219
173
  private
220
174
 
175
+ # Safely resolve a class name using Object.const_get with security validations
176
+ # This replaces the previous eval() usage to prevent code injection attacks.
177
+ #
178
+ # Security features:
179
+ # - Validates class name format (must start with capital letter)
180
+ # - Prevents access to dangerous system classes
181
+ # - Blocks relative class references (starting with ::)
182
+ # - Provides clear error messages for debugging
183
+ #
184
+ # @param class_name [String] The class name to resolve
185
+ # @return [Class] The resolved class
186
+ # @raise [ArgumentError] if class name is invalid, forbidden, or not found
187
+ def safe_const_get(class_name)
188
+ # Validate class name format
189
+ unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
190
+ raise ArgumentError, "Invalid class name format: #{class_name}"
191
+ end
192
+
193
+ # Remove any leading :: then add exactly one
194
+ fq_class_name = "::#{class_name.sub(/^::+/, '')}"
195
+
196
+ # Prevent dangerous class names
197
+ forbidden_classes = %w[
198
+ Kernel Module Class Object BasicObject
199
+ File Dir IO Process System
200
+ Binding Proc Method UnboundMethod
201
+ Thread ThreadGroup Fiber
202
+ ObjectSpace GC
203
+ ]
204
+
205
+ if forbidden_classes.include?(class_name) || class_name.start_with?('::')
206
+ raise ArgumentError, "Forbidden class name: #{class_name}"
207
+ end
208
+
209
+ begin
210
+ # Always guarantee exactly two leading colons
211
+ Object.const_get(fq_class_name)
212
+ rescue NameError => e
213
+ raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
214
+ end
215
+ end
216
+
221
217
  def compile(path)
222
218
  keys = []
223
219
 
@@ -1,6 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/route_handlers/base.rb
2
+
4
3
  require 'json'
5
4
 
6
5
  class Otto
@@ -3,6 +3,7 @@
3
3
  # lib/otto/route_handlers/factory.rb
4
4
 
5
5
  require_relative 'base'
6
+ require_relative '../security/authentication/route_auth_wrapper'
6
7
 
7
8
  class Otto
8
9
  module RouteHandlers
@@ -13,16 +14,30 @@ class Otto
13
14
  # @param otto_instance [Otto] The Otto instance for configuration access
14
15
  # @return [BaseHandler] Appropriate handler for the route
15
16
  def self.create_handler(route_definition, otto_instance = nil)
16
- case route_definition.kind
17
- when :logic
18
- LogicClassHandler.new(route_definition, otto_instance)
19
- when :instance
20
- InstanceMethodHandler.new(route_definition, otto_instance)
21
- when :class
22
- ClassMethodHandler.new(route_definition, otto_instance)
23
- else
24
- raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
17
+ # Create base handler based on route kind
18
+ handler_class = case route_definition.kind
19
+ when :logic then LogicClassHandler
20
+ when :instance then InstanceMethodHandler
21
+ when :class then ClassMethodHandler
22
+ else
23
+ raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
24
+ end
25
+
26
+ handler = handler_class.new(route_definition, otto_instance)
27
+
28
+ # Always wrap with RouteAuthWrapper to ensure env['otto.strategy_result'] is set
29
+ # - Routes WITH auth requirement: Enforces authentication
30
+ # - Routes WITHOUT auth requirement: Sets anonymous StrategyResult
31
+ if otto_instance&.auth_config
32
+ handler = Otto::Security::Authentication::RouteAuthWrapper.new(
33
+ handler,
34
+ route_definition,
35
+ otto_instance.auth_config,
36
+ otto_instance.security_config
37
+ )
25
38
  end
39
+
40
+ handler
26
41
  end
27
42
  end
28
43
  end
@@ -17,8 +17,8 @@ class Otto
17
17
  res = Rack::Response.new
18
18
 
19
19
  begin
20
- # Get strategy result (guaranteed to exist from auth middleware)
21
- strategy_result = env['otto.strategy_result'] || Otto::Security::Authentication::StrategyResult.anonymous
20
+ # Get strategy result (guaranteed to exist from RouteAuthWrapper)
21
+ strategy_result = env['otto.strategy_result']
22
22
 
23
23
  # Initialize Logic class with new signature: context, params, locale
24
24
  logic_params = req.params.merge(extra_params)
@@ -0,0 +1,44 @@
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
+ AuthFailure = Data.define(:failure_reason, :auth_method) do
10
+ # AuthFailure represents authentication failure
11
+ # Returned by strategies when authentication fails
12
+ # Contains failure reason for error messages
13
+
14
+ # Check if authenticated - always false for failures
15
+ #
16
+ # @return [Boolean] False (failures are never authenticated)
17
+ def authenticated?
18
+ false
19
+ end
20
+
21
+ # Check if anonymous - always true for failures
22
+ #
23
+ # @return [Boolean] True (failures have no user)
24
+ def anonymous?
25
+ true
26
+ end
27
+
28
+ # Get empty user context for failures
29
+ #
30
+ # @return [Hash] Empty hash
31
+ def user_context
32
+ {}
33
+ end
34
+
35
+ # Create a string representation for debugging
36
+ #
37
+ # @return [String] Debug representation
38
+ def inspect
39
+ "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -13,7 +13,7 @@ class Otto
13
13
  # Check if the request meets the authentication requirements
14
14
  # @param env [Hash] Rack environment
15
15
  # @param requirement [String] Authentication requirement string
16
- # @return [Otto::Security::Authentication::StrategyResult, nil] StrategyResult for success, nil for failure
16
+ # @return [Otto::Security::Authentication::StrategyResult, Otto::Security::Authentication::AuthFailure] StrategyResult for success, AuthFailure for failure
17
17
  def authenticate(env, requirement)
18
18
  raise NotImplementedError, 'Subclasses must implement #authenticate'
19
19
  end
@@ -30,10 +30,10 @@ class Otto
30
30
  )
31
31
  end
32
32
 
33
- # Helper for authentication failure - return FailureResult
33
+ # Helper for authentication failure - return AuthFailure
34
34
  def failure(reason = nil)
35
35
  Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
36
- Otto::Security::Authentication::FailureResult.new(
36
+ Otto::Security::Authentication::AuthFailure.new(
37
37
  failure_reason: reason || 'Authentication failed',
38
38
  auth_method: self.class.name.split('::').last
39
39
  )
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/authentication/route_auth_wrapper.rb
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ # Wraps route handlers to enforce authentication requirements
9
+ #
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).
13
+ #
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'], env['otto.user']
19
+ # 5. If auth fails, return 401 or redirect
20
+ # 6. If auth succeeds, call wrapped handler
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)
26
+ #
27
+ class RouteAuthWrapper
28
+ attr_reader :wrapped_handler, :route_definition, :auth_config, :security_config
29
+
30
+ def initialize(wrapped_handler, route_definition, auth_config, security_config = nil)
31
+ @wrapped_handler = wrapped_handler
32
+ @route_definition = route_definition
33
+ @auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
34
+ @security_config = security_config
35
+ @strategy_cache = {} # Cache resolved strategies to avoid repeated lookups
36
+ end
37
+
38
+ # Execute authentication then call wrapped handler
39
+ #
40
+ # For routes WITHOUT auth requirement: Sets anonymous StrategyResult
41
+ # For routes WITH auth requirement: Enforces authentication
42
+ #
43
+ # @param env [Hash] Rack environment
44
+ # @param extra_params [Hash] Additional parameters
45
+ # @return [Array] Rack response array
46
+ def call(env, extra_params = {})
47
+ auth_requirement = route_definition.auth_requirement
48
+
49
+ # Routes without auth requirement get anonymous StrategyResult
50
+ unless auth_requirement
51
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
52
+ metadata = { ip: env['REMOTE_ADDR'] }
53
+ metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
54
+
55
+ result = StrategyResult.anonymous(metadata: metadata)
56
+ env['otto.strategy_result'] = result
57
+ env['otto.user'] = nil
58
+ env['otto.user_context'] = result.user_context
59
+ return wrapped_handler.call(env, extra_params)
60
+ end
61
+
62
+ # Routes WITH auth requirement: Execute authentication strategy
63
+ strategy = get_strategy(auth_requirement)
64
+
65
+ unless strategy
66
+ Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
67
+ return unauthorized_response(env, "Authentication strategy not configured")
68
+ end
69
+
70
+ # Execute the strategy
71
+ result = strategy.authenticate(env, auth_requirement)
72
+
73
+ # Handle authentication failure
74
+ if result.is_a?(AuthFailure)
75
+ # Create anonymous result with failure info for logging/auditing
76
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
77
+ metadata = {
78
+ ip: env['REMOTE_ADDR'],
79
+ auth_failure: result.failure_reason,
80
+ attempted_strategy: auth_requirement
81
+ }
82
+ metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
83
+
84
+ env['otto.strategy_result'] = StrategyResult.anonymous(metadata: metadata)
85
+ env['otto.user'] = nil
86
+ env['otto.user_context'] = {}
87
+ return auth_failure_response(env, result)
88
+ end
89
+
90
+ # Set environment variables for controllers/logic on success
91
+ env['otto.strategy_result'] = result
92
+ env['otto.user'] = result.user
93
+ env['otto.user_context'] = result.user_context
94
+
95
+ # SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
96
+ # We must ensure env['rack.session'] and strategy_result.session reference
97
+ # the SAME object so that:
98
+ # 1. Logic classes write to strategy_result.session
99
+ # 2. Rack's session middleware persists env['rack.session']
100
+ # 3. Changes from (1) are included in (2)
101
+ #
102
+ # Using merge! instead would break this - the objects must be identical.
103
+ env['rack.session'] = result.session if result.is_a?(StrategyResult) && result.session
104
+
105
+ # Authentication succeeded - call wrapped handler
106
+ wrapped_handler.call(env, extra_params)
107
+ end
108
+
109
+ private
110
+
111
+ # Get strategy from auth_config hash with sophisticated pattern matching
112
+ #
113
+ # Supports:
114
+ # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
115
+ # - Prefix match: 'role:admin' → looks up 'role' strategy
116
+ # - Fallback: 'role:*' → creates default RoleStrategy
117
+ # - Fallback: 'permission:*' → creates default PermissionStrategy
118
+ #
119
+ # Results are cached to avoid repeated lookups for the same requirement.
120
+ #
121
+ # @param requirement [String] Auth requirement from route
122
+ # @return [AuthStrategy, nil] Strategy instance or nil
123
+ def get_strategy(requirement)
124
+ return nil unless auth_config && auth_config[:auth_strategies]
125
+
126
+ # Check cache first
127
+ return @strategy_cache[requirement] if @strategy_cache.key?(requirement)
128
+
129
+ # Try exact match first - this has highest priority
130
+ strategy = auth_config[:auth_strategies][requirement]
131
+ if strategy
132
+ @strategy_cache[requirement] = strategy
133
+ return strategy
134
+ end
135
+
136
+ # For colon-separated requirements like "role:admin", try prefix match
137
+ if requirement.include?(':')
138
+ prefix = requirement.split(':', 2).first
139
+
140
+ # Check if we have a strategy registered for the prefix
141
+ prefix_strategy = auth_config[:auth_strategies][prefix]
142
+ if prefix_strategy
143
+ @strategy_cache[requirement] = prefix_strategy
144
+ return prefix_strategy
145
+ end
146
+
147
+ # Try fallback patterns for role: and permission: requirements
148
+ if requirement.start_with?('role:')
149
+ # Cache the fallback strategy under 'role:' key to create it only once
150
+ strategy = @strategy_cache['role:'] ||= begin
151
+ auth_config[:auth_strategies]['role'] || Strategies::RoleStrategy.new([])
152
+ end
153
+ @strategy_cache[requirement] = strategy
154
+ return strategy
155
+ elsif requirement.start_with?('permission:')
156
+ # Cache the fallback strategy under 'permission:' key
157
+ strategy = @strategy_cache['permission:'] ||= begin
158
+ auth_config[:auth_strategies]['permission'] || Strategies::PermissionStrategy.new([])
159
+ end
160
+ @strategy_cache[requirement] = strategy
161
+ return strategy
162
+ end
163
+ end
164
+
165
+ # Cache nil results too to avoid repeated failed lookups
166
+ @strategy_cache[requirement] = nil
167
+ nil
168
+ end
169
+
170
+ # Generate 401 response for authentication failure
171
+ #
172
+ # @param env [Hash] Rack environment
173
+ # @param result [AuthFailure] Failure result from strategy
174
+ # @return [Array] Rack response array
175
+ def auth_failure_response(env, result)
176
+ # Check if request wants JSON
177
+ accept_header = env['HTTP_ACCEPT'] || ''
178
+ wants_json = accept_header.include?('application/json')
179
+
180
+ if wants_json
181
+ json_auth_error(result)
182
+ else
183
+ html_auth_error(result)
184
+ end
185
+ end
186
+
187
+ # Generate JSON 401 response
188
+ #
189
+ # @param result [AuthFailure] Failure result
190
+ # @return [Array] Rack response array
191
+ def json_auth_error(result)
192
+ body = {
193
+ error: 'Authentication Required',
194
+ message: result.failure_reason || 'Not authenticated',
195
+ timestamp: Time.now.to_i
196
+ }.to_json
197
+
198
+ headers = {
199
+ 'content-type' => 'application/json',
200
+ 'content-length' => body.bytesize.to_s
201
+ }
202
+
203
+ # Add security headers if available
204
+ merge_security_headers!(headers)
205
+
206
+ [401, headers, [body]]
207
+ end
208
+
209
+ # Generate HTML 401 response or redirect
210
+ #
211
+ # @param result [AuthFailure] Failure result
212
+ # @return [Array] Rack response array
213
+ def html_auth_error(result)
214
+ # For HTML requests, redirect to login
215
+ login_path = auth_config[:login_path] || '/signin'
216
+
217
+ headers = { 'location' => login_path }
218
+
219
+ # Add security headers if available
220
+ merge_security_headers!(headers)
221
+
222
+ [302, headers, ["Redirecting to #{login_path}"]]
223
+ end
224
+
225
+ # Generate generic unauthorized response
226
+ #
227
+ # @param env [Hash] Rack environment
228
+ # @param message [String] Error message
229
+ # @return [Array] Rack response array
230
+ def unauthorized_response(env, message)
231
+ accept_header = env['HTTP_ACCEPT'] || ''
232
+ wants_json = accept_header.include?('application/json')
233
+
234
+ if wants_json
235
+ body = { error: message }.to_json
236
+ headers = {
237
+ 'content-type' => 'application/json',
238
+ 'content-length' => body.bytesize.to_s
239
+ }
240
+ merge_security_headers!(headers)
241
+ [401, headers, [body]]
242
+ else
243
+ headers = { 'content-type' => 'text/plain' }
244
+ merge_security_headers!(headers)
245
+ [401, headers, [message]]
246
+ end
247
+ end
248
+
249
+ # Merge security headers into response headers
250
+ #
251
+ # @param headers [Hash] Response headers hash to merge into
252
+ def merge_security_headers!(headers)
253
+ return unless security_config
254
+
255
+ headers.merge!(security_config.security_headers)
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -8,9 +8,13 @@ class Otto
8
8
  module Authentication
9
9
  module Strategies
10
10
  # Public access strategy - always allows access
11
- class PublicStrategy < AuthStrategy
11
+ class NoAuthStrategy < AuthStrategy
12
12
  def authenticate(env, _requirement)
13
- Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
13
+ # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
14
+ metadata = { ip: env['REMOTE_ADDR'] }
15
+ metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
16
+
17
+ Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
14
18
  end
15
19
  end
16
20
  end