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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+ require_relative '../strategy_result'
5
+
6
+ class Otto
7
+ module Security
8
+ module Authentication
9
+ module Strategies
10
+ # Public access strategy - always allows access
11
+ class PublicStrategy < AuthStrategy
12
+ def authenticate(env, _requirement)
13
+ Otto::Security::Authentication::StrategyResult.anonymous(metadata: { ip: env['REMOTE_ADDR'] })
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
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
+ # Role-based authentication strategy
10
+ class RoleStrategy < AuthStrategy
11
+ def initialize(allowed_roles, session_key: 'user_roles')
12
+ @allowed_roles = Array(allowed_roles)
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_roles = session[@session_key] || []
21
+ user_roles = Array(user_roles)
22
+
23
+ # Create user data from session
24
+ user_data = { user_roles: user_roles, session: session }
25
+
26
+ # For requirements like "role:admin", extract the role part
27
+ if requirement.include?(':')
28
+ required_role = requirement.split(':', 2).last
29
+ if user_roles.include?(required_role)
30
+ success(user: user_data, user_roles: user_roles, required_role: required_role)
31
+ else
32
+ failure("Insufficient privileges - requires role: #{required_role}")
33
+ end
34
+ else
35
+ # For direct strategy matches, check if user has any of the allowed roles
36
+ matching_roles = user_roles & @allowed_roles
37
+ if matching_roles.any?
38
+ success(user: user_data, user_roles: user_roles, allowed_roles: @allowed_roles,
39
+ matching_roles: matching_roles)
40
+ else
41
+ failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
42
+ end
43
+ end
44
+ end
45
+
46
+ def user_context(env)
47
+ session = env['rack.session']
48
+ return {} unless session
49
+
50
+ user_roles = session[@session_key] || []
51
+ { user_roles: Array(user_roles) }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
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
+ # Session-based authentication strategy
10
+ class SessionStrategy < AuthStrategy
11
+ def initialize(session_key: 'user_id', session_store: nil)
12
+ @session_key = session_key
13
+ @session_store = session_store
14
+ end
15
+
16
+ def authenticate(env, _requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_id = session[@session_key]
21
+ return failure('Not authenticated') unless user_id
22
+
23
+ # Create a simple user hash for the generic strategy
24
+ user_data = { id: user_id, user_id: user_id }
25
+ success(session: session, user: user_data, auth_method: 'session')
26
+ end
27
+
28
+ def user_context(env)
29
+ session = env['rack.session']
30
+ return {} unless session
31
+
32
+ user_id = session[@session_key]
33
+ return {} unless user_id
34
+
35
+ { user_id: user_id }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/otto/security/authentication/strategy_result.rb
4
+
5
+ # StrategyResult is an immutable data structure that holds the result of an
6
+ # authentication strategy. It contains session, user, and metadata needed by
7
+ # Otto Logic classes.
8
+ #
9
+ # @example Basic usage
10
+ # result = StrategyResult.new(
11
+ # session: { id: 'abc123', user_id: 1 },
12
+ # user: user_model_instance, # Actual user model, not a hash
13
+ # auth_method: 'token',
14
+ # metadata: { ip: '127.0.0.1' }
15
+ # )
16
+ #
17
+ # result.authenticated? #=> true
18
+ # result.has_role?('admin') #=> true
19
+ # result.user.name #=> 'John' (assuming user model has name method)
20
+ #
21
+ class Otto
22
+ module Security
23
+ module Authentication
24
+ StrategyResult = Data.define(:session, :user, :auth_method, :metadata) do
25
+ # Create an anonymous (unauthenticated) result
26
+ # @return [StrategyResult] Anonymous result with empty session and nil user
27
+ def self.anonymous(metadata: {})
28
+ new(
29
+ session: {},
30
+ user: nil, # Changed from {} to nil - clearer semantics
31
+ auth_method: 'anonymous',
32
+ metadata: metadata
33
+ )
34
+ end
35
+
36
+ # Check if the request is authenticated (has a user)
37
+ # @return [Boolean] True if user is present, false otherwise
38
+ def authenticated?
39
+ !user.nil?
40
+ end
41
+
42
+ # Check if the request is anonymous (no user)
43
+ # @return [Boolean] True if not authenticated
44
+ def anonymous?
45
+ user.nil?
46
+ end
47
+
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
+ # Check if the user has a specific role
59
+ # @param role [String, Symbol] Role to check
60
+ # @return [Boolean] True if user has the role
61
+ def has_role?(role)
62
+ return false unless authenticated?
63
+
64
+ # Try user model methods first, fall back to hash access for backward compatibility
65
+ if user.respond_to?(:role)
66
+ user.role.to_s == role.to_s
67
+ elsif user.respond_to?(:has_role?)
68
+ user.has_role?(role)
69
+ elsif user.is_a?(Hash)
70
+ user_role = user[:role] || user['role']
71
+ user_role.to_s == role.to_s
72
+ else
73
+ false
74
+ end
75
+ end
76
+
77
+ # Check if the user has a specific permission
78
+ # @param permission [String, Symbol] Permission to check
79
+ # @return [Boolean] True if user has the permission
80
+ def has_permission?(permission)
81
+ return false unless authenticated?
82
+
83
+ # Try user model methods first, fall back to hash access for backward compatibility
84
+ if user.respond_to?(:has_permission?)
85
+ user.has_permission?(permission)
86
+ elsif user.respond_to?(:permissions)
87
+ permissions = user.permissions || []
88
+ permissions = [permissions] unless permissions.is_a?(Array)
89
+ permissions.map(&:to_s).include?(permission.to_s)
90
+ elsif user.is_a?(Hash)
91
+ permissions = user[:permissions] || user['permissions'] || []
92
+ permissions = [permissions] unless permissions.is_a?(Array)
93
+ permissions.map(&:to_s).include?(permission.to_s)
94
+ else
95
+ false
96
+ end
97
+ end
98
+
99
+ # Check if the user has any of the specified roles
100
+ # @param roles [Array<String, Symbol>] Roles to check
101
+ # @return [Boolean] True if user has any of the roles
102
+ def has_any_role?(*roles)
103
+ roles.flatten.any? { |role| has_role?(role) }
104
+ end
105
+
106
+ # Check if the user has any of the specified permissions
107
+ # @param permissions [Array<String, Symbol>] Permissions to check
108
+ # @return [Boolean] True if user has any of the permissions
109
+ def has_any_permission?(*permissions)
110
+ permissions.flatten.any? { |permission| has_permission?(permission) }
111
+ end
112
+
113
+ # Get user ID from various possible locations
114
+ # @return [String, Integer, nil] User ID or nil
115
+ def user_id
116
+ return nil unless authenticated?
117
+
118
+ # Try user model methods first, fall back to hash access and session
119
+ if user.respond_to?(:id)
120
+ user.id
121
+ elsif user.respond_to?(:user_id)
122
+ user.user_id
123
+ elsif user.is_a?(Hash)
124
+ user[:id] || user['id'] || user[:user_id] || user['user_id']
125
+ end || session[:user_id] || session['user_id']
126
+ end
127
+
128
+ # Get user name from various possible locations
129
+ # @return [String, nil] User name or nil
130
+ def user_name
131
+ return nil unless authenticated?
132
+
133
+ # Try user model methods first, fall back to hash access
134
+ if user.respond_to?(:name)
135
+ user.name
136
+ elsif user.respond_to?(:username)
137
+ user.username
138
+ elsif user.is_a?(Hash)
139
+ user[:name] || user['name'] || user[:username] || user['username']
140
+ end
141
+ end
142
+
143
+ # Get session ID from various possible locations
144
+ # @return [String, nil] Session ID or nil
145
+ def session_id
146
+ session[:id] || session['id'] || session[:session_id] || session['session_id']
147
+ end
148
+
149
+ # Get all user roles as an array
150
+ # @return [Array<String>] Array of roles (empty if none)
151
+ def roles
152
+ return [] unless authenticated?
153
+
154
+ roles_data = user[:roles] || user['roles']
155
+ if roles_data.is_a?(Array)
156
+ roles_data.map(&:to_s)
157
+ elsif roles_data
158
+ [roles_data.to_s]
159
+ else
160
+ role = user[:role] || user['role']
161
+ role ? [role.to_s] : []
162
+ end
163
+ end
164
+
165
+ # Get all user permissions as an array
166
+ # @return [Array<String>] Array of permissions (empty if none)
167
+ def permissions
168
+ return [] unless authenticated?
169
+
170
+ perms = user[:permissions] || user['permissions'] || []
171
+ perms = [perms] unless perms.is_a?(Array)
172
+ perms.map(&:to_s)
173
+ end
174
+
175
+ # Create a string representation for debugging
176
+ # @return [String] Debug representation
177
+ def inspect
178
+ if authenticated?
179
+ "#<StrategyResult authenticated user=#{user_name || user_id} roles=#{roles} method=#{auth_method}>"
180
+ else
181
+ "#<StrategyResult anonymous method=#{auth_method}>"
182
+ end
183
+ end
184
+
185
+ # Get user context - a hash containing user-specific information and metadata
186
+ # @return [Hash] User context hash
187
+ def user_context
188
+ if authenticated?
189
+ case auth_method
190
+ when 'session'
191
+ { user_id: user_id, session: session }
192
+ else
193
+ metadata
194
+ end
195
+ else
196
+ case auth_method
197
+ when 'anonymous'
198
+ {}
199
+ else
200
+ metadata
201
+ end
202
+ end
203
+ end
204
+
205
+ # Create a hash representation
206
+ # @return [Hash] Hash representation of the context
207
+ def to_h
208
+ {
209
+ session: session,
210
+ user: user,
211
+ auth_method: auth_method,
212
+ metadata: metadata,
213
+ authenticated: authenticated?,
214
+ user_id: user_id,
215
+ user_name: user_name,
216
+ roles: roles,
217
+ permissions: permissions
218
+ }
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -1,289 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/otto/security/authentication.rb
2
4
  #
3
- # Configurable authentication strategy system for Otto framework
4
- # Provides pluggable authentication patterns that can be customized per application
5
- #
6
- # Usage:
7
- # otto = Otto.new('routes.txt', {
8
- # auth_strategies: {
9
- # 'publically' => PublicStrategy.new,
10
- # 'authenticated' => SessionStrategy.new,
11
- # 'role:admin' => RoleStrategy.new(['admin']),
12
- # 'api_key' => APIKeyStrategy.new
13
- # }
14
- # })
15
-
16
- class Otto
17
- module Security
18
- # Base class for all authentication strategies
19
- class AuthStrategy
20
- # Check if the request meets the authentication requirements
21
- # @param env [Hash] Rack environment
22
- # @param requirement [String] Authentication requirement string
23
- # @return [AuthResult] Result containing success status and context
24
- def authenticate(env, requirement)
25
- raise NotImplementedError, 'Subclasses must implement #authenticate'
26
- end
27
-
28
- # Optional: Extract user context for authenticated requests
29
- # @param env [Hash] Rack environment
30
- # @return [Hash] User context hash
31
- def user_context(env)
32
- {}
33
- end
34
-
35
- protected
36
-
37
- # Helper to create successful auth result
38
- def success(user_context = {})
39
- AuthResult.new(true, user_context)
40
- end
41
-
42
- # Helper to create failed auth result
43
- def failure(reason = 'Authentication failed')
44
- AuthResult.new(false, {}, reason)
45
- end
46
- end
47
-
48
- # Result object for authentication attempts
49
- class AuthResult
50
- attr_reader :user_context, :failure_reason
51
-
52
- def initialize(success, user_context = {}, failure_reason = nil)
53
- @success = success
54
- @user_context = user_context
55
- @failure_reason = failure_reason
56
- end
57
-
58
- def success?
59
- @success
60
- end
61
-
62
- def failure?
63
- !@success
64
- end
65
- end
66
-
67
- # Public access strategy - always allows access
68
- class PublicStrategy < AuthStrategy
69
- def authenticate(env, requirement)
70
- success
71
- end
72
- end
73
-
74
- # Session-based authentication strategy
75
- class SessionStrategy < AuthStrategy
76
- def initialize(session_key: 'user_id', session_store: nil)
77
- @session_key = session_key
78
- @session_store = session_store
79
- end
80
-
81
- def authenticate(env, requirement)
82
- session = env['rack.session']
83
- return failure('No session available') unless session
84
-
85
- user_id = session[@session_key]
86
- return failure('Not authenticated') unless user_id
87
-
88
- success(user_id: user_id, session: session)
89
- end
90
-
91
- def user_context(env)
92
- session = env['rack.session']
93
- return {} unless session
94
-
95
- user_id = session[@session_key]
96
- user_id ? { user_id: user_id } : {}
97
- end
98
- end
99
-
100
- # Role-based authentication strategy
101
- class RoleStrategy < AuthStrategy
102
- def initialize(allowed_roles, session_key: 'user_roles')
103
- @allowed_roles = Array(allowed_roles)
104
- @session_key = session_key
105
- end
106
-
107
- def authenticate(env, requirement)
108
- session = env['rack.session']
109
- return failure('No session available') unless session
110
-
111
- user_roles = session[@session_key] || []
112
- user_roles = Array(user_roles)
113
-
114
- # For requirements like "role:admin", extract the role part
115
- if requirement.include?(':')
116
- required_role = requirement.split(':', 2).last
117
- if user_roles.include?(required_role)
118
- success(user_roles: user_roles, required_role: required_role)
119
- else
120
- failure("Insufficient privileges - requires role: #{required_role}")
121
- end
122
- else
123
- # For direct strategy matches, check if user has any of the allowed roles
124
- matching_roles = user_roles & @allowed_roles
125
- if matching_roles.any?
126
- success(user_roles: user_roles, allowed_roles: @allowed_roles, matching_roles: matching_roles)
127
- else
128
- failure("Insufficient privileges - requires one of roles: #{@allowed_roles.join(', ')}")
129
- end
130
- end
131
- end
132
-
133
- def user_context(env)
134
- session = env['rack.session']
135
- return {} unless session
136
-
137
- user_roles = session[@session_key] || []
138
- { user_roles: Array(user_roles) }
139
- end
140
- end
141
-
142
- # API key authentication strategy
143
- class APIKeyStrategy < AuthStrategy
144
- def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
145
- @api_keys = Array(api_keys)
146
- @header_name = header_name
147
- @param_name = param_name
148
- end
149
-
150
- def authenticate(env, requirement)
151
- # Try header first, then query parameter
152
- api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
5
+ # Index file for Otto authentication module
6
+ # Requires all authentication-related components for backward compatibility
153
7
 
154
- if api_key.nil?
155
- request = Rack::Request.new(env)
156
- api_key = request.params[@param_name]
157
- end
8
+ require_relative 'authentication/auth_strategy'
9
+ require_relative 'authentication/strategy_result'
10
+ require_relative 'authentication/failure_result'
11
+ require_relative 'authentication/authentication_middleware'
158
12
 
159
- return failure('No API key provided') unless api_key
13
+ # Load all strategies
14
+ require_relative 'authentication/strategies/public_strategy'
15
+ require_relative 'authentication/strategies/session_strategy'
16
+ require_relative 'authentication/strategies/role_strategy'
17
+ require_relative 'authentication/strategies/api_key_strategy'
18
+ require_relative 'authentication/strategies/permission_strategy'
160
19
 
161
- if @api_keys.empty? || @api_keys.include?(api_key)
162
- success(api_key: api_key)
163
- else
164
- failure('Invalid API key')
165
- end
166
- end
167
- end
168
-
169
- # Permission-based authentication strategy
170
- class PermissionStrategy < AuthStrategy
171
- def initialize(required_permissions, session_key: 'user_permissions')
172
- @required_permissions = Array(required_permissions)
173
- @session_key = session_key
174
- end
175
-
176
- def authenticate(env, requirement)
177
- session = env['rack.session']
178
- return failure('No session available') unless session
179
-
180
- user_permissions = session[@session_key] || []
181
- user_permissions = Array(user_permissions)
182
-
183
- # Extract permission from requirement (e.g., "permission:write" -> "write")
184
- required_permission = requirement.split(':', 2).last
185
-
186
- if user_permissions.include?(required_permission)
187
- success(user_permissions: user_permissions, required_permission: required_permission)
188
- else
189
- failure("Insufficient privileges - requires permission: #{required_permission}")
190
- end
191
- end
192
-
193
- def user_context(env)
194
- session = env['rack.session']
195
- return {} unless session
196
-
197
- user_permissions = session[@session_key] || []
198
- { user_permissions: Array(user_permissions) }
199
- end
200
- end
201
-
202
- # Authentication middleware that enforces route-level auth requirements
203
- class AuthenticationMiddleware
204
- def initialize(app, config = {})
205
- @app = app
206
- @config = config
207
- @strategies = config[:auth_strategies] || {}
208
- @default_strategy = config[:default_auth_strategy] || 'publically'
209
-
210
- # Add default public strategy if not provided
211
- @strategies['publically'] ||= PublicStrategy.new
212
- end
213
-
214
- def call(env)
215
- # Check if this route has auth requirements
216
- route_definition = env['otto.route_definition']
217
- return @app.call(env) unless route_definition
218
-
219
- auth_requirement = route_definition.auth_requirement
220
- return @app.call(env) unless auth_requirement
221
-
222
- # Find appropriate strategy
223
- strategy = find_strategy(auth_requirement)
224
- unless strategy
225
- return auth_error_response("Unknown authentication strategy: #{auth_requirement}")
226
- end
227
-
228
- # Perform authentication
229
- auth_result = strategy.authenticate(env, auth_requirement)
230
-
231
- if auth_result.success?
232
- # Add user context to environment for handlers to use
233
- env['otto.user_context'] = auth_result.user_context
234
- env['otto.auth_result'] = auth_result
235
- @app.call(env)
236
- else
237
- auth_error_response(auth_result.failure_reason)
238
- end
239
- end
240
-
241
- private
242
-
243
- def find_strategy(requirement)
244
- # Try exact match first - this has highest priority
245
- return @strategies[requirement] if @strategies[requirement]
246
-
247
- # For colon-separated requirements like "role:admin", try prefix match
248
- if requirement.include?(':')
249
- prefix = requirement.split(':', 2).first
250
-
251
- # Check if we have a strategy registered for the prefix
252
- prefix_strategy = @strategies[prefix]
253
- return prefix_strategy if prefix_strategy
254
-
255
- # Try fallback patterns for role: and permission: requirements
256
- if requirement.start_with?('role:')
257
- return @strategies['role'] || RoleStrategy.new([])
258
- elsif requirement.start_with?('permission:')
259
- return @strategies['permission'] || PermissionStrategy.new([])
260
- end
261
- end
262
-
263
- nil
264
- end
265
-
266
- def auth_error_response(message)
267
- body = JSON.generate({
268
- error: 'Authentication Required',
269
- message: message,
270
- timestamp: Time.now.to_i
271
- })
272
-
273
- headers = {
274
- 'Content-Type' => 'application/json',
275
- 'Content-Length' => body.bytesize.to_s
276
- }
277
-
278
- # Add security headers if available from config hash or Otto instance
279
- if @config.is_a?(Hash) && @config[:security_headers]
280
- headers.merge!(@config[:security_headers])
281
- elsif @config.respond_to?(:security_config) && @config.security_config
282
- headers.merge!(@config.security_config.security_headers)
283
- end
284
-
285
- [401, headers, [body]]
286
- end
287
- end
20
+ class Otto
21
+ module Security
22
+ # Backward compatibility aliases for the old namespace
23
+ AuthStrategy = Authentication::AuthStrategy
24
+ PublicStrategy = Authentication::Strategies::PublicStrategy
25
+ SessionStrategy = Authentication::Strategies::SessionStrategy
26
+ RoleStrategy = Authentication::Strategies::RoleStrategy
27
+ APIKeyStrategy = Authentication::Strategies::APIKeyStrategy
28
+ PermissionStrategy = Authentication::Strategies::PermissionStrategy
29
+ AuthenticationMiddleware = Authentication::AuthenticationMiddleware
288
30
  end
31
+
32
+ # Top-level backward compatibility aliases
33
+ StrategyResult = Security::Authentication::StrategyResult
34
+ FailureResult = Security::Authentication::FailureResult
289
35
  end