otto 2.0.0.pre1 → 2.0.0.pre2
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/Gemfile +1 -1
- data/Gemfile.lock +19 -18
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -0
- data/lib/otto/core/configuration.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +80 -0
- data/lib/otto/core/router.rb +7 -6
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/response.rb +22 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/factory.rb +22 -9
- data/lib/otto/security/authentication/authentication_middleware.rb +29 -12
- data/lib/otto/security/authentication/failure_result.rb +15 -7
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +1 -1
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/config.rb +0 -11
- data/lib/otto/security/configurator.rb +2 -2
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +2 -3
- data/otto.gemspec +2 -0
- metadata +26 -6
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
@@ -7,26 +7,34 @@ class Otto
|
|
7
7
|
module Authentication
|
8
8
|
# Failure result for authentication failures
|
9
9
|
FailureResult = Data.define(:failure_reason, :auth_method) do
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
def failure?
|
15
|
-
true
|
16
|
-
end
|
10
|
+
# FailureResult represents authentication failure
|
11
|
+
# Returned by strategies when authentication fails
|
12
|
+
# Contains failure reason for error messages
|
17
13
|
|
14
|
+
# Check if authenticated - always false for failures
|
15
|
+
#
|
16
|
+
# @return [Boolean] False (failures are never authenticated)
|
18
17
|
def authenticated?
|
19
18
|
false
|
20
19
|
end
|
21
20
|
|
21
|
+
# Check if anonymous - always true for failures
|
22
|
+
#
|
23
|
+
# @return [Boolean] True (failures have no user)
|
22
24
|
def anonymous?
|
23
25
|
true
|
24
26
|
end
|
25
27
|
|
28
|
+
# Get empty user context for failures
|
29
|
+
#
|
30
|
+
# @return [Hash] Empty hash
|
26
31
|
def user_context
|
27
32
|
{}
|
28
33
|
end
|
29
34
|
|
35
|
+
# Create a string representation for debugging
|
36
|
+
#
|
37
|
+
# @return [String] Debug representation
|
30
38
|
def inspect
|
31
39
|
"#<FailureResult reason=#{failure_reason.inspect} method=#{auth_method}>"
|
32
40
|
end
|
@@ -0,0 +1,149 @@
|
|
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
|
29
|
+
|
30
|
+
def initialize(wrapped_handler, route_definition, auth_config)
|
31
|
+
@wrapped_handler = wrapped_handler
|
32
|
+
@route_definition = route_definition
|
33
|
+
@auth_config = auth_config # Hash: { auth_strategies: {}, default_auth_strategy: 'publicly' }
|
34
|
+
end
|
35
|
+
|
36
|
+
# Execute authentication then call wrapped handler
|
37
|
+
#
|
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
|
+
# Execute authentication strategy for this route
|
43
|
+
auth_requirement = route_definition.auth_requirement
|
44
|
+
strategy = get_strategy(auth_requirement)
|
45
|
+
|
46
|
+
unless strategy
|
47
|
+
Otto.logger.error "[RouteAuthWrapper] No strategy found for requirement: #{auth_requirement}"
|
48
|
+
return unauthorized_response(env, "Authentication strategy not configured")
|
49
|
+
end
|
50
|
+
|
51
|
+
# Execute the strategy
|
52
|
+
result = strategy.authenticate(env, auth_requirement)
|
53
|
+
|
54
|
+
# Set environment variables for controllers/logic
|
55
|
+
env['otto.strategy_result'] = result
|
56
|
+
env['otto.user'] = result.user if result.is_a?(StrategyResult)
|
57
|
+
env['otto.user_context'] = result.user_context if result.is_a?(StrategyResult)
|
58
|
+
|
59
|
+
# Handle authentication failure
|
60
|
+
if result.is_a?(FailureResult)
|
61
|
+
return auth_failure_response(env, result)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Authentication succeeded - call wrapped handler
|
65
|
+
wrapped_handler.call(env, extra_params)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Get strategy from auth_config hash
|
71
|
+
#
|
72
|
+
# @param requirement [String] Auth requirement from route
|
73
|
+
# @return [AuthStrategy, nil] Strategy instance or nil
|
74
|
+
def get_strategy(requirement)
|
75
|
+
return nil unless auth_config && auth_config[:auth_strategies]
|
76
|
+
|
77
|
+
auth_config[:auth_strategies][requirement]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Generate 401 response for authentication failure
|
81
|
+
#
|
82
|
+
# @param env [Hash] Rack environment
|
83
|
+
# @param result [FailureResult] Failure result from strategy
|
84
|
+
# @return [Array] Rack response array
|
85
|
+
def auth_failure_response(env, result)
|
86
|
+
# Check if request wants JSON
|
87
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
88
|
+
wants_json = accept_header.include?('application/json')
|
89
|
+
|
90
|
+
if wants_json
|
91
|
+
json_auth_error(result)
|
92
|
+
else
|
93
|
+
html_auth_error(result)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Generate JSON 401 response
|
98
|
+
#
|
99
|
+
# @param result [FailureResult] Failure result
|
100
|
+
# @return [Array] Rack response array
|
101
|
+
def json_auth_error(result)
|
102
|
+
body = {
|
103
|
+
error: 'Authentication Required',
|
104
|
+
message: result.failure_reason || 'Not authenticated',
|
105
|
+
timestamp: Time.now.to_i
|
106
|
+
}.to_json
|
107
|
+
|
108
|
+
[
|
109
|
+
401,
|
110
|
+
{ 'content-type' => 'application/json' },
|
111
|
+
[body]
|
112
|
+
]
|
113
|
+
end
|
114
|
+
|
115
|
+
# Generate HTML 401 response or redirect
|
116
|
+
#
|
117
|
+
# @param result [FailureResult] Failure result
|
118
|
+
# @return [Array] Rack response array
|
119
|
+
def html_auth_error(result)
|
120
|
+
# For HTML requests, redirect to login
|
121
|
+
login_path = auth_config[:login_path] || '/signin'
|
122
|
+
|
123
|
+
[
|
124
|
+
302,
|
125
|
+
{ 'location' => login_path },
|
126
|
+
["Redirecting to #{login_path}"]
|
127
|
+
]
|
128
|
+
end
|
129
|
+
|
130
|
+
# Generate generic unauthorized response
|
131
|
+
#
|
132
|
+
# @param env [Hash] Rack environment
|
133
|
+
# @param message [String] Error message
|
134
|
+
# @return [Array] Rack response array
|
135
|
+
def unauthorized_response(env, message)
|
136
|
+
accept_header = env['HTTP_ACCEPT'] || ''
|
137
|
+
wants_json = accept_header.include?('application/json')
|
138
|
+
|
139
|
+
if wants_json
|
140
|
+
body = { error: message }.to_json
|
141
|
+
[401, { 'content-type' => 'application/json' }, [body]]
|
142
|
+
else
|
143
|
+
[401, { 'content-type' => 'text/plain' }, [message]]
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -8,7 +8,7 @@ class Otto
|
|
8
8
|
module Authentication
|
9
9
|
module Strategies
|
10
10
|
# Public access strategy - always allows access
|
11
|
-
class
|
11
|
+
class NoAuthStrategy < AuthStrategy
|
12
12
|
def authenticate(env, _requirement)
|
13
13
|
Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
|
14
14
|
end
|
@@ -22,40 +22,142 @@ class Otto
|
|
22
22
|
module Security
|
23
23
|
module Authentication
|
24
24
|
StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
|
25
|
+
# =====================================================================
|
26
|
+
# USAGE PATTERNS - READ THIS FIRST
|
27
|
+
# =====================================================================
|
28
|
+
#
|
29
|
+
# StrategyResult represents authentication state for a request.
|
30
|
+
# It serves TWO distinct purposes that must not be confused:
|
31
|
+
#
|
32
|
+
# 1. REQUEST STATE: Current session/user information
|
33
|
+
# - Use `authenticated?` to check if session has a user
|
34
|
+
# - Available on ALL requests (anonymous or authenticated)
|
35
|
+
#
|
36
|
+
# 2. AUTH ATTEMPT OUTCOME: Whether authentication just succeeded
|
37
|
+
# - Use `auth_attempt_succeeded?` to check if auth strategy ran
|
38
|
+
# - Only true when route had auth=... requirement AND succeeded
|
39
|
+
#
|
40
|
+
# CREATION PATTERNS
|
41
|
+
# -----------------
|
42
|
+
#
|
43
|
+
# StrategyResult should ONLY be created by:
|
44
|
+
#
|
45
|
+
# 1. Otto's AuthenticationMiddleware (automatic, route-based)
|
46
|
+
# - Routes WITH auth=...: Creates result from strategy execution
|
47
|
+
# - Routes WITHOUT auth=...: Creates anonymous result
|
48
|
+
#
|
49
|
+
# 2. Auth app router (manual, for Logic class compatibility)
|
50
|
+
# - Manually builds StrategyResult for Roda routes
|
51
|
+
# - Maintains same interface as Otto controllers
|
52
|
+
#
|
53
|
+
# APPLICATION CODE SHOULD NOT manually create StrategyResult!
|
54
|
+
# Instead, access session directly or rely on middleware.
|
55
|
+
#
|
56
|
+
# SESSION CONTRACT
|
57
|
+
# ----------------
|
58
|
+
#
|
59
|
+
# For multi-app architectures with shared session:
|
60
|
+
#
|
61
|
+
# Required session keys for authenticated state:
|
62
|
+
# session['authenticated'] # Boolean flag
|
63
|
+
# session['identity_id'] # User/customer ID
|
64
|
+
# session['authenticated_at'] # Timestamp
|
65
|
+
#
|
66
|
+
# Optional session keys:
|
67
|
+
# session['email'] # User email
|
68
|
+
# session['ip_address'] # Client IP
|
69
|
+
# session['user_agent'] # Client UA
|
70
|
+
# session['locale'] # User locale
|
71
|
+
#
|
72
|
+
# Advanced mode adds:
|
73
|
+
# session['account_external_id'] # Rodauth external_id
|
74
|
+
# session['advanced_account_id'] # Rodauth account ID
|
75
|
+
#
|
76
|
+
# EXAMPLES
|
77
|
+
# --------
|
78
|
+
#
|
79
|
+
# Check if user in session (registration flow):
|
80
|
+
# class CreateAccount
|
81
|
+
# def raise_concerns
|
82
|
+
# # Block registration if already logged in
|
83
|
+
# raise FormError, "Already signed up" if @context.authenticated?
|
84
|
+
# end
|
85
|
+
# end
|
86
|
+
#
|
87
|
+
# Check if auth just succeeded (post-login redirect):
|
88
|
+
# class LoginHandler
|
89
|
+
# def process
|
90
|
+
# if @context.auth_attempt_succeeded?
|
91
|
+
# redirect_to dashboard_path
|
92
|
+
# end
|
93
|
+
# end
|
94
|
+
# end
|
95
|
+
#
|
96
|
+
# Distinguish between the two:
|
97
|
+
# @context.authenticated? #=> true (user in session)
|
98
|
+
# @context.auth_attempt_succeeded? #=> false (no auth route)
|
99
|
+
#
|
100
|
+
# # vs route with auth=session:
|
101
|
+
# @context.authenticated? #=> true (user in session)
|
102
|
+
# @context.auth_attempt_succeeded? #=> true (strategy just ran)
|
103
|
+
#
|
104
|
+
# =====================================================================
|
105
|
+
|
25
106
|
# Create an anonymous (unauthenticated) result
|
26
|
-
#
|
107
|
+
#
|
108
|
+
# Used by middleware for routes without auth requirements
|
109
|
+
# and by PublicStrategy for publicly accessible routes.
|
110
|
+
#
|
111
|
+
# @param metadata [Hash] Optional metadata (IP, user agent, etc.)
|
112
|
+
# @return [StrategyResult] Anonymous result with nil user
|
27
113
|
def self.anonymous(metadata: {})
|
28
114
|
new(
|
29
115
|
session: {},
|
30
|
-
user: nil,
|
116
|
+
user: nil,
|
31
117
|
auth_method: 'anonymous',
|
32
118
|
metadata: metadata
|
33
119
|
)
|
34
120
|
end
|
35
121
|
|
36
|
-
# Check if the request
|
37
|
-
#
|
122
|
+
# Check if the request has an authenticated user in session
|
123
|
+
#
|
124
|
+
# This checks REQUEST STATE, not auth attempt outcome.
|
125
|
+
# Returns true if session contains a user, regardless of
|
126
|
+
# whether authentication just occurred or was from a previous request.
|
127
|
+
#
|
128
|
+
# @return [Boolean] True if user is present in session
|
129
|
+
# @example
|
130
|
+
# # Block registration if user already logged in
|
131
|
+
# raise FormError if @context.authenticated?
|
38
132
|
def authenticated?
|
39
133
|
!user.nil?
|
40
134
|
end
|
41
135
|
|
42
|
-
# Check if
|
136
|
+
# Check if authentication strategy just executed and succeeded
|
137
|
+
#
|
138
|
+
# This checks AUTH ATTEMPT OUTCOME, not just session state.
|
139
|
+
# Returns true only when:
|
140
|
+
# 1. Route had an auth=... requirement (not anonymous/public)
|
141
|
+
# 2. Auth strategy executed
|
142
|
+
# 3. Authentication succeeded (user authenticated)
|
143
|
+
#
|
144
|
+
# @return [Boolean] True if auth strategy just succeeded
|
145
|
+
# @example
|
146
|
+
# # Redirect after successful login
|
147
|
+
# redirect_to dashboard if @context.auth_attempt_succeeded?
|
148
|
+
def auth_attempt_succeeded?
|
149
|
+
authenticated? && auth_method.to_s != 'anonymous'
|
150
|
+
end
|
151
|
+
|
152
|
+
# Check if the request is anonymous (no user in session)
|
153
|
+
#
|
43
154
|
# @return [Boolean] True if not authenticated
|
44
155
|
def anonymous?
|
45
156
|
user.nil?
|
46
157
|
end
|
47
158
|
|
48
|
-
# Success/failure methods for compatibility
|
49
|
-
def success?
|
50
|
-
true # If we have a StrategyResult, authentication succeeded
|
51
|
-
end
|
52
|
-
|
53
|
-
def failure?
|
54
|
-
false # Failures return nil, not a StrategyResult
|
55
|
-
end
|
56
|
-
|
57
|
-
|
58
159
|
# Check if the user has a specific role
|
160
|
+
#
|
59
161
|
# @param role [String, Symbol] Role to check
|
60
162
|
# @return [Boolean] True if user has the role
|
61
163
|
def has_role?(role)
|
@@ -75,6 +177,7 @@ class Otto
|
|
75
177
|
end
|
76
178
|
|
77
179
|
# Check if the user has a specific permission
|
180
|
+
#
|
78
181
|
# @param permission [String, Symbol] Permission to check
|
79
182
|
# @return [Boolean] True if user has the permission
|
80
183
|
def has_permission?(permission)
|
@@ -97,6 +200,7 @@ class Otto
|
|
97
200
|
end
|
98
201
|
|
99
202
|
# Check if the user has any of the specified roles
|
203
|
+
#
|
100
204
|
# @param roles [Array<String, Symbol>] Roles to check
|
101
205
|
# @return [Boolean] True if user has any of the roles
|
102
206
|
def has_any_role?(*roles)
|
@@ -104,6 +208,7 @@ class Otto
|
|
104
208
|
end
|
105
209
|
|
106
210
|
# Check if the user has any of the specified permissions
|
211
|
+
#
|
107
212
|
# @param permissions [Array<String, Symbol>] Permissions to check
|
108
213
|
# @return [Boolean] True if user has any of the permissions
|
109
214
|
def has_any_permission?(*permissions)
|
@@ -111,6 +216,7 @@ class Otto
|
|
111
216
|
end
|
112
217
|
|
113
218
|
# Get user ID from various possible locations
|
219
|
+
#
|
114
220
|
# @return [String, Integer, nil] User ID or nil
|
115
221
|
def user_id
|
116
222
|
return nil unless authenticated?
|
@@ -126,6 +232,7 @@ class Otto
|
|
126
232
|
end
|
127
233
|
|
128
234
|
# Get user name from various possible locations
|
235
|
+
#
|
129
236
|
# @return [String, nil] User name or nil
|
130
237
|
def user_name
|
131
238
|
return nil unless authenticated?
|
@@ -141,12 +248,14 @@ class Otto
|
|
141
248
|
end
|
142
249
|
|
143
250
|
# Get session ID from various possible locations
|
251
|
+
#
|
144
252
|
# @return [String, nil] Session ID or nil
|
145
253
|
def session_id
|
146
254
|
session[:id] || session['id'] || session[:session_id] || session['session_id']
|
147
255
|
end
|
148
256
|
|
149
257
|
# Get all user roles as an array
|
258
|
+
#
|
150
259
|
# @return [Array<String>] Array of roles (empty if none)
|
151
260
|
def roles
|
152
261
|
return [] unless authenticated?
|
@@ -163,6 +272,7 @@ class Otto
|
|
163
272
|
end
|
164
273
|
|
165
274
|
# Get all user permissions as an array
|
275
|
+
#
|
166
276
|
# @return [Array<String>] Array of permissions (empty if none)
|
167
277
|
def permissions
|
168
278
|
return [] unless authenticated?
|
@@ -173,6 +283,7 @@ class Otto
|
|
173
283
|
end
|
174
284
|
|
175
285
|
# Create a string representation for debugging
|
286
|
+
#
|
176
287
|
# @return [String] Debug representation
|
177
288
|
def inspect
|
178
289
|
if authenticated?
|
@@ -183,6 +294,7 @@ class Otto
|
|
183
294
|
end
|
184
295
|
|
185
296
|
# Get user context - a hash containing user-specific information and metadata
|
297
|
+
#
|
186
298
|
# @return [Hash] User context hash
|
187
299
|
def user_context
|
188
300
|
if authenticated?
|
@@ -203,6 +315,7 @@ class Otto
|
|
203
315
|
end
|
204
316
|
|
205
317
|
# Create a hash representation
|
318
|
+
#
|
206
319
|
# @return [Hash] Hash representation of the context
|
207
320
|
def to_h
|
208
321
|
{
|
@@ -211,6 +324,7 @@ class Otto
|
|
211
324
|
auth_method: auth_method,
|
212
325
|
metadata: metadata,
|
213
326
|
authenticated: authenticated?,
|
327
|
+
auth_attempt_succeeded: auth_attempt_succeeded?,
|
214
328
|
user_id: user_id,
|
215
329
|
user_name: user_name,
|
216
330
|
roles: roles,
|
@@ -11,7 +11,7 @@ require_relative 'authentication/failure_result'
|
|
11
11
|
require_relative 'authentication/authentication_middleware'
|
12
12
|
|
13
13
|
# Load all strategies
|
14
|
-
require_relative 'authentication/strategies/
|
14
|
+
require_relative 'authentication/strategies/noauth_strategy'
|
15
15
|
require_relative 'authentication/strategies/session_strategy'
|
16
16
|
require_relative 'authentication/strategies/role_strategy'
|
17
17
|
require_relative 'authentication/strategies/api_key_strategy'
|
@@ -21,7 +21,7 @@ class Otto
|
|
21
21
|
module Security
|
22
22
|
# Backward compatibility aliases for the old namespace
|
23
23
|
AuthStrategy = Authentication::AuthStrategy
|
24
|
-
|
24
|
+
NoAuthStrategy = Authentication::Strategies::NoAuthStrategy
|
25
25
|
SessionStrategy = Authentication::Strategies::SessionStrategy
|
26
26
|
RoleStrategy = Authentication::Strategies::RoleStrategy
|
27
27
|
APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
|
data/lib/otto/security/config.rb
CHANGED
@@ -149,11 +149,6 @@ class Otto
|
|
149
149
|
signature = Digest::SHA256.hexdigest(hash_input)
|
150
150
|
csrf_token = "#{token}:#{signature}"
|
151
151
|
|
152
|
-
puts '=== CSRF Generation ==='
|
153
|
-
puts "hash_input: #{hash_input.inspect}"
|
154
|
-
puts "signature: #{signature}"
|
155
|
-
puts "csrf_token: #{csrf_token}"
|
156
|
-
|
157
152
|
csrf_token
|
158
153
|
end
|
159
154
|
|
@@ -168,12 +163,6 @@ class Otto
|
|
168
163
|
expected_signature = Digest::SHA256.hexdigest(hash_input)
|
169
164
|
comparison_result = secure_compare(signature, expected_signature)
|
170
165
|
|
171
|
-
puts '=== CSRF Verification ==='
|
172
|
-
puts "hash_input: #{hash_input.inspect}"
|
173
|
-
puts "received_signature: #{signature}"
|
174
|
-
puts "expected_signature: #{expected_signature}"
|
175
|
-
puts "match: #{comparison_result}"
|
176
|
-
|
177
166
|
comparison_result
|
178
167
|
end
|
179
168
|
|
@@ -21,7 +21,7 @@ class Otto
|
|
21
21
|
@security_config = security_config
|
22
22
|
@middleware_stack = middleware_stack
|
23
23
|
# Use provided auth_config or initialize a new one
|
24
|
-
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: '
|
24
|
+
@auth_config = auth_config || { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
25
25
|
end
|
26
26
|
|
27
27
|
# Unified security configuration method with sensible defaults
|
@@ -192,7 +192,7 @@ class Otto
|
|
192
192
|
#
|
193
193
|
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
194
194
|
# @param default_strategy [String] Default strategy to use when none specified
|
195
|
-
def configure_auth_strategies(strategies, default_strategy: '
|
195
|
+
def configure_auth_strategies(strategies, default_strategy: 'noauth')
|
196
196
|
# Merge new strategies with existing ones, preserving shared state
|
197
197
|
@auth_config[:auth_strategies].merge!(strategies)
|
198
198
|
@auth_config[:default_auth_strategy] = default_strategy
|
@@ -7,6 +7,21 @@ class Otto
|
|
7
7
|
module Middleware
|
8
8
|
# Middleware for applying rate limiting to HTTP requests
|
9
9
|
class RateLimitMiddleware
|
10
|
+
# NOTE: This middleware is a CONFIGURATOR, not an enforcer.
|
11
|
+
#
|
12
|
+
# Actual rate limiting is performed by Rack::Attack globally via
|
13
|
+
# configure_rack_attack!. This middleware registers during initialization
|
14
|
+
# and then passes through all requests.
|
15
|
+
#
|
16
|
+
# To enforce rate limits, Rack::Attack must be added to the middleware
|
17
|
+
# stack BEFORE Otto's router (typically done by the hosting application).
|
18
|
+
#
|
19
|
+
# Example (config.ru):
|
20
|
+
# use Rack::Attack # Must come before Otto
|
21
|
+
# run otto
|
22
|
+
#
|
23
|
+
# The call method is a pass-through; rate limiting happens in Rack::Attack.
|
24
|
+
|
10
25
|
def initialize(app, security_config = nil)
|
11
26
|
@app = app
|
12
27
|
@security_config = security_config
|
@@ -19,10 +34,11 @@ class Otto
|
|
19
34
|
end
|
20
35
|
end
|
21
36
|
|
37
|
+
# Pass-through call - actual rate limiting handled by Rack::Attack
|
38
|
+
#
|
39
|
+
# This middleware does not enforce limits itself. It configures
|
40
|
+
# Rack::Attack during initialization, then delegates all requests.
|
22
41
|
def call(env)
|
23
|
-
return @app.call(env) unless @rate_limiter_available
|
24
|
-
|
25
|
-
# Let rack-attack handle the rate limiting
|
26
42
|
@app.call(env)
|
27
43
|
end
|
28
44
|
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
@@ -57,7 +57,6 @@ require_relative 'otto/utils'
|
|
57
57
|
# otto.enable_frame_protection!
|
58
58
|
#
|
59
59
|
# Configuration Data class to replace OpenStruct
|
60
|
-
# Configuration Data class to replace OpenStruct
|
61
60
|
# Configuration class to replace OpenStruct
|
62
61
|
class ConfigData
|
63
62
|
def initialize(**kwargs)
|
@@ -311,7 +310,7 @@ class Otto
|
|
311
310
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
312
311
|
def add_auth_strategy(name, strategy)
|
313
312
|
# Ensure auth_config is initialized (handles edge case where it might be nil)
|
314
|
-
@auth_config = { auth_strategies: {}, default_auth_strategy: '
|
313
|
+
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
|
315
314
|
|
316
315
|
@auth_config[:auth_strategies][name] = strategy
|
317
316
|
|
@@ -349,7 +348,7 @@ class Otto
|
|
349
348
|
@security_config = Otto::Security::Config.new
|
350
349
|
@middleware = Otto::Core::MiddlewareStack.new
|
351
350
|
# Initialize @auth_config first so it can be shared with the configurator
|
352
|
-
@auth_config = { auth_strategies: {}, default_auth_strategy: '
|
351
|
+
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
353
352
|
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
354
353
|
end
|
355
354
|
|
data/otto.gemspec
CHANGED
@@ -16,6 +16,8 @@ Gem::Specification.new do |spec|
|
|
16
16
|
|
17
17
|
spec.required_ruby_version = ['>= 3.2', '< 4.0']
|
18
18
|
|
19
|
+
# Logger is not part of the default gems as of Ruby 3.5.0
|
20
|
+
spec.add_dependency 'logger', '~> 1', '< 2.0'
|
19
21
|
|
20
22
|
spec.add_dependency 'rack', '~> 3.1', '< 4.0'
|
21
23
|
spec.add_dependency 'rack-parser', '~> 0.7'
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: otto
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.0.
|
4
|
+
version: 2.0.0.pre2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Delano Mandelbaum
|
@@ -9,6 +9,26 @@ bindir: bin
|
|
9
9
|
cert_chain: []
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: logger
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '1'
|
19
|
+
- - "<"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '2.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - "~>"
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '1'
|
29
|
+
- - "<"
|
30
|
+
- !ruby/object:Gem::Version
|
31
|
+
version: '2.0'
|
12
32
|
- !ruby/object:Gem::Dependency
|
13
33
|
name: rack
|
14
34
|
requirement: !ruby/object:Gem::Requirement
|
@@ -107,13 +127,11 @@ files:
|
|
107
127
|
- LICENSE.txt
|
108
128
|
- README.md
|
109
129
|
- bin/rspec
|
110
|
-
- changelog.d/20250911_235619_delano_next.rst
|
111
|
-
- changelog.d/20250912_123055_delano_remove_ostruct.rst
|
112
|
-
- changelog.d/20250912_175625_claude_delano_remove_ostruct.rst
|
113
130
|
- changelog.d/README.md
|
114
131
|
- changelog.d/scriv.ini
|
115
132
|
- docs/.gitignore
|
116
133
|
- docs/migrating/v2.0.0-pre1.md
|
134
|
+
- docs/migrating/v2.0.0-pre2.md
|
117
135
|
- examples/.gitignore
|
118
136
|
- examples/advanced_routes/README.md
|
119
137
|
- examples/advanced_routes/app.rb
|
@@ -175,6 +193,7 @@ files:
|
|
175
193
|
- lib/otto/core/router.rb
|
176
194
|
- lib/otto/core/uri_generator.rb
|
177
195
|
- lib/otto/design_system.rb
|
196
|
+
- lib/otto/env_keys.rb
|
178
197
|
- lib/otto/helpers/base.rb
|
179
198
|
- lib/otto/helpers/request.rb
|
180
199
|
- lib/otto/helpers/response.rb
|
@@ -184,8 +203,8 @@ files:
|
|
184
203
|
- lib/otto/mcp/rate_limiting.rb
|
185
204
|
- lib/otto/mcp/registry.rb
|
186
205
|
- lib/otto/mcp/route_parser.rb
|
206
|
+
- lib/otto/mcp/schema_validation.rb
|
187
207
|
- lib/otto/mcp/server.rb
|
188
|
-
- lib/otto/mcp/validation.rb
|
189
208
|
- lib/otto/response_handlers.rb
|
190
209
|
- lib/otto/response_handlers/auto.rb
|
191
210
|
- lib/otto/response_handlers/base.rb
|
@@ -207,9 +226,10 @@ files:
|
|
207
226
|
- lib/otto/security/authentication/auth_strategy.rb
|
208
227
|
- lib/otto/security/authentication/authentication_middleware.rb
|
209
228
|
- lib/otto/security/authentication/failure_result.rb
|
229
|
+
- lib/otto/security/authentication/route_auth_wrapper.rb
|
210
230
|
- lib/otto/security/authentication/strategies/api_key_strategy.rb
|
231
|
+
- lib/otto/security/authentication/strategies/noauth_strategy.rb
|
211
232
|
- lib/otto/security/authentication/strategies/permission_strategy.rb
|
212
|
-
- lib/otto/security/authentication/strategies/public_strategy.rb
|
213
233
|
- lib/otto/security/authentication/strategies/role_strategy.rb
|
214
234
|
- lib/otto/security/authentication/strategies/session_strategy.rb
|
215
235
|
- lib/otto/security/authentication/strategy_result.rb
|