otto 1.6.0 → 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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -2
  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 +26 -344
  7. data/CHANGELOG.rst +131 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +11 -4
  10. data/Gemfile.lock +38 -42
  11. data/README.md +2 -0
  12. data/bin/rspec +4 -4
  13. data/changelog.d/README.md +120 -0
  14. data/changelog.d/scriv.ini +5 -0
  15. data/docs/.gitignore +2 -0
  16. data/docs/migrating/v2.0.0-pre1.md +276 -0
  17. data/docs/migrating/v2.0.0-pre2.md +345 -0
  18. data/examples/.gitignore +1 -0
  19. data/examples/advanced_routes/README.md +33 -0
  20. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  21. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  22. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  27. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  29. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  30. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  31. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  32. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  33. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  34. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  35. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  36. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  37. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  38. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  39. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  40. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  41. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  42. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  43. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  45. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  46. data/examples/advanced_routes/app.rb +33 -0
  47. data/examples/advanced_routes/config.rb +23 -0
  48. data/examples/advanced_routes/config.ru +7 -0
  49. data/examples/advanced_routes/puma.rb +20 -0
  50. data/examples/advanced_routes/routes +167 -0
  51. data/examples/advanced_routes/run.rb +39 -0
  52. data/examples/advanced_routes/test.rb +58 -0
  53. data/examples/authentication_strategies/README.md +32 -0
  54. data/examples/authentication_strategies/app/auth.rb +68 -0
  55. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  56. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  57. data/examples/authentication_strategies/config.ru +24 -0
  58. data/examples/authentication_strategies/routes +37 -0
  59. data/examples/basic/README.md +29 -0
  60. data/examples/basic/app.rb +7 -35
  61. data/examples/basic/routes +0 -9
  62. data/examples/mcp_demo/README.md +87 -0
  63. data/examples/mcp_demo/app.rb +29 -34
  64. data/examples/mcp_demo/config.ru +9 -60
  65. data/examples/security_features/README.md +46 -0
  66. data/examples/security_features/app.rb +23 -24
  67. data/examples/security_features/config.ru +8 -10
  68. data/lib/otto/core/configuration.rb +167 -0
  69. data/lib/otto/core/error_handler.rb +86 -0
  70. data/lib/otto/core/file_safety.rb +61 -0
  71. data/lib/otto/core/middleware_stack.rb +237 -0
  72. data/lib/otto/core/router.rb +184 -0
  73. data/lib/otto/core/uri_generator.rb +44 -0
  74. data/lib/otto/design_system.rb +7 -5
  75. data/lib/otto/env_keys.rb +114 -0
  76. data/lib/otto/helpers/base.rb +5 -21
  77. data/lib/otto/helpers/request.rb +10 -8
  78. data/lib/otto/helpers/response.rb +27 -4
  79. data/lib/otto/helpers/validation.rb +9 -7
  80. data/lib/otto/mcp/auth/token.rb +10 -9
  81. data/lib/otto/mcp/protocol.rb +24 -27
  82. data/lib/otto/mcp/rate_limiting.rb +8 -3
  83. data/lib/otto/mcp/registry.rb +7 -2
  84. data/lib/otto/mcp/route_parser.rb +10 -15
  85. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
  86. data/lib/otto/mcp/server.rb +45 -22
  87. data/lib/otto/response_handlers/auto.rb +39 -0
  88. data/lib/otto/response_handlers/base.rb +16 -0
  89. data/lib/otto/response_handlers/default.rb +16 -0
  90. data/lib/otto/response_handlers/factory.rb +39 -0
  91. data/lib/otto/response_handlers/json.rb +34 -0
  92. data/lib/otto/response_handlers/redirect.rb +25 -0
  93. data/lib/otto/response_handlers/view.rb +24 -0
  94. data/lib/otto/response_handlers.rb +9 -135
  95. data/lib/otto/route.rb +51 -55
  96. data/lib/otto/route_definition.rb +15 -18
  97. data/lib/otto/route_handlers/base.rb +121 -0
  98. data/lib/otto/route_handlers/class_method.rb +89 -0
  99. data/lib/otto/route_handlers/factory.rb +42 -0
  100. data/lib/otto/route_handlers/instance_method.rb +69 -0
  101. data/lib/otto/route_handlers/lambda.rb +59 -0
  102. data/lib/otto/route_handlers/logic_class.rb +93 -0
  103. data/lib/otto/route_handlers.rb +10 -405
  104. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  105. data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
  106. data/lib/otto/security/authentication/failure_result.rb +44 -0
  107. data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
  108. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  109. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  112. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  113. data/lib/otto/security/authentication/strategy_result.rb +337 -0
  114. data/lib/otto/security/authentication.rb +28 -282
  115. data/lib/otto/security/config.rb +14 -23
  116. data/lib/otto/security/configurator.rb +219 -0
  117. data/lib/otto/security/csrf.rb +8 -143
  118. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  119. data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
  120. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  121. data/lib/otto/security/rate_limiter.rb +86 -0
  122. data/lib/otto/security/rate_limiting.rb +10 -105
  123. data/lib/otto/security/validator.rb +8 -253
  124. data/lib/otto/static.rb +3 -0
  125. data/lib/otto/utils.rb +14 -0
  126. data/lib/otto/version.rb +3 -1
  127. data/lib/otto.rb +141 -498
  128. data/otto.gemspec +4 -2
  129. metadata +99 -18
  130. data/examples/dynamic_pages/app.rb +0 -115
  131. data/examples/dynamic_pages/config.ru +0 -30
  132. data/examples/dynamic_pages/routes +0 -21
  133. data/examples/helpers_demo/app.rb +0 -244
  134. data/examples/helpers_demo/config.ru +0 -26
  135. data/examples/helpers_demo/routes +0 -7
  136. data/lib/concurrent_cache_store.rb +0 -68
@@ -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
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # API key authentication strategy
10
+ class APIKeyStrategy < AuthStrategy
11
+ def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
12
+ @api_keys = Array(api_keys)
13
+ @header_name = header_name
14
+ @param_name = param_name
15
+ end
16
+
17
+ def authenticate(env, _requirement)
18
+ # Try header first, then query parameter
19
+ api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
20
+
21
+ if api_key.nil?
22
+ request = Rack::Request.new(env)
23
+ api_key = request.params[@param_name]
24
+ end
25
+
26
+ return failure('No API key provided') unless api_key
27
+
28
+ if @api_keys.empty? || @api_keys.include?(api_key)
29
+ # Create a simple user hash for API key authentication
30
+ user_data = { api_key: api_key }
31
+ success(user: user_data, api_key: api_key)
32
+ else
33
+ failure('Invalid API key')
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,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 NoAuthStrategy < 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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../auth_strategy'
4
+
5
+ class Otto
6
+ module Security
7
+ module Authentication
8
+ module Strategies
9
+ # Permission-based authentication strategy
10
+ class PermissionStrategy < AuthStrategy
11
+ def initialize(required_permissions, session_key: 'user_permissions')
12
+ @required_permissions = Array(required_permissions)
13
+ @session_key = session_key
14
+ end
15
+
16
+ def authenticate(env, requirement)
17
+ session = env['rack.session']
18
+ return failure('No session available') unless session
19
+
20
+ user_permissions = session[@session_key] || []
21
+ user_permissions = Array(user_permissions)
22
+
23
+ # Create user data from session
24
+ user_data = { user_permissions: user_permissions, session: session }
25
+
26
+ # Extract permission from requirement (e.g., "permission:write" -> "write")
27
+ required_permission = requirement.split(':', 2).last
28
+
29
+ if user_permissions.include?(required_permission)
30
+ success(user: user_data, user_permissions: user_permissions, required_permission: required_permission)
31
+ else
32
+ failure("Insufficient privileges - requires permission: #{required_permission}")
33
+ end
34
+ end
35
+
36
+ def user_context(env)
37
+ session = env['rack.session']
38
+ return {} unless session
39
+
40
+ user_permissions = session[@session_key] || []
41
+ { user_permissions: Array(user_permissions) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -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,337 @@
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
+ # =====================================================================
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
+
106
+ # Create an anonymous (unauthenticated) result
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
113
+ def self.anonymous(metadata: {})
114
+ new(
115
+ session: {},
116
+ user: nil,
117
+ auth_method: 'anonymous',
118
+ metadata: metadata
119
+ )
120
+ end
121
+
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?
132
+ def authenticated?
133
+ !user.nil?
134
+ end
135
+
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
+ #
154
+ # @return [Boolean] True if not authenticated
155
+ def anonymous?
156
+ user.nil?
157
+ end
158
+
159
+ # Check if the user has a specific role
160
+ #
161
+ # @param role [String, Symbol] Role to check
162
+ # @return [Boolean] True if user has the role
163
+ def has_role?(role)
164
+ return false unless authenticated?
165
+
166
+ # Try user model methods first, fall back to hash access for backward compatibility
167
+ if user.respond_to?(:role)
168
+ user.role.to_s == role.to_s
169
+ elsif user.respond_to?(:has_role?)
170
+ user.has_role?(role)
171
+ elsif user.is_a?(Hash)
172
+ user_role = user[:role] || user['role']
173
+ user_role.to_s == role.to_s
174
+ else
175
+ false
176
+ end
177
+ end
178
+
179
+ # Check if the user has a specific permission
180
+ #
181
+ # @param permission [String, Symbol] Permission to check
182
+ # @return [Boolean] True if user has the permission
183
+ def has_permission?(permission)
184
+ return false unless authenticated?
185
+
186
+ # Try user model methods first, fall back to hash access for backward compatibility
187
+ if user.respond_to?(:has_permission?)
188
+ user.has_permission?(permission)
189
+ elsif user.respond_to?(:permissions)
190
+ permissions = user.permissions || []
191
+ permissions = [permissions] unless permissions.is_a?(Array)
192
+ permissions.map(&:to_s).include?(permission.to_s)
193
+ elsif user.is_a?(Hash)
194
+ permissions = user[:permissions] || user['permissions'] || []
195
+ permissions = [permissions] unless permissions.is_a?(Array)
196
+ permissions.map(&:to_s).include?(permission.to_s)
197
+ else
198
+ false
199
+ end
200
+ end
201
+
202
+ # Check if the user has any of the specified roles
203
+ #
204
+ # @param roles [Array<String, Symbol>] Roles to check
205
+ # @return [Boolean] True if user has any of the roles
206
+ def has_any_role?(*roles)
207
+ roles.flatten.any? { |role| has_role?(role) }
208
+ end
209
+
210
+ # Check if the user has any of the specified permissions
211
+ #
212
+ # @param permissions [Array<String, Symbol>] Permissions to check
213
+ # @return [Boolean] True if user has any of the permissions
214
+ def has_any_permission?(*permissions)
215
+ permissions.flatten.any? { |permission| has_permission?(permission) }
216
+ end
217
+
218
+ # Get user ID from various possible locations
219
+ #
220
+ # @return [String, Integer, nil] User ID or nil
221
+ def user_id
222
+ return nil unless authenticated?
223
+
224
+ # Try user model methods first, fall back to hash access and session
225
+ if user.respond_to?(:id)
226
+ user.id
227
+ elsif user.respond_to?(:user_id)
228
+ user.user_id
229
+ elsif user.is_a?(Hash)
230
+ user[:id] || user['id'] || user[:user_id] || user['user_id']
231
+ end || session[:user_id] || session['user_id']
232
+ end
233
+
234
+ # Get user name from various possible locations
235
+ #
236
+ # @return [String, nil] User name or nil
237
+ def user_name
238
+ return nil unless authenticated?
239
+
240
+ # Try user model methods first, fall back to hash access
241
+ if user.respond_to?(:name)
242
+ user.name
243
+ elsif user.respond_to?(:username)
244
+ user.username
245
+ elsif user.is_a?(Hash)
246
+ user[:name] || user['name'] || user[:username] || user['username']
247
+ end
248
+ end
249
+
250
+ # Get session ID from various possible locations
251
+ #
252
+ # @return [String, nil] Session ID or nil
253
+ def session_id
254
+ session[:id] || session['id'] || session[:session_id] || session['session_id']
255
+ end
256
+
257
+ # Get all user roles as an array
258
+ #
259
+ # @return [Array<String>] Array of roles (empty if none)
260
+ def roles
261
+ return [] unless authenticated?
262
+
263
+ roles_data = user[:roles] || user['roles']
264
+ if roles_data.is_a?(Array)
265
+ roles_data.map(&:to_s)
266
+ elsif roles_data
267
+ [roles_data.to_s]
268
+ else
269
+ role = user[:role] || user['role']
270
+ role ? [role.to_s] : []
271
+ end
272
+ end
273
+
274
+ # Get all user permissions as an array
275
+ #
276
+ # @return [Array<String>] Array of permissions (empty if none)
277
+ def permissions
278
+ return [] unless authenticated?
279
+
280
+ perms = user[:permissions] || user['permissions'] || []
281
+ perms = [perms] unless perms.is_a?(Array)
282
+ perms.map(&:to_s)
283
+ end
284
+
285
+ # Create a string representation for debugging
286
+ #
287
+ # @return [String] Debug representation
288
+ def inspect
289
+ if authenticated?
290
+ "#<StrategyResult authenticated user=#{user_name || user_id} roles=#{roles} method=#{auth_method}>"
291
+ else
292
+ "#<StrategyResult anonymous method=#{auth_method}>"
293
+ end
294
+ end
295
+
296
+ # Get user context - a hash containing user-specific information and metadata
297
+ #
298
+ # @return [Hash] User context hash
299
+ def user_context
300
+ if authenticated?
301
+ case auth_method
302
+ when 'session'
303
+ { user_id: user_id, session: session }
304
+ else
305
+ metadata
306
+ end
307
+ else
308
+ case auth_method
309
+ when 'anonymous'
310
+ {}
311
+ else
312
+ metadata
313
+ end
314
+ end
315
+ end
316
+
317
+ # Create a hash representation
318
+ #
319
+ # @return [Hash] Hash representation of the context
320
+ def to_h
321
+ {
322
+ session: session,
323
+ user: user,
324
+ auth_method: auth_method,
325
+ metadata: metadata,
326
+ authenticated: authenticated?,
327
+ auth_attempt_succeeded: auth_attempt_succeeded?,
328
+ user_id: user_id,
329
+ user_name: user_name,
330
+ roles: roles,
331
+ permissions: permissions
332
+ }
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end