otto 2.0.0.pre7 → 2.0.0.pre9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +5 -8
  6. data/CHANGELOG.rst +87 -2
  7. data/Gemfile.lock +6 -6
  8. data/README.md +20 -0
  9. data/docs/.gitignore +2 -0
  10. data/docs/modern-authentication-authorization-landscape.md +558 -0
  11. data/docs/multi-strategy-authentication-design.md +1401 -0
  12. data/lib/otto/core/error_handler.rb +19 -8
  13. data/lib/otto/core/freezable.rb +0 -2
  14. data/lib/otto/core/middleware_stack.rb +12 -8
  15. data/lib/otto/core/router.rb +25 -31
  16. data/lib/otto/errors.rb +92 -0
  17. data/lib/otto/mcp/rate_limiting.rb +6 -2
  18. data/lib/otto/mcp/schema_validation.rb +1 -1
  19. data/lib/otto/response_handlers/json.rb +1 -3
  20. data/lib/otto/response_handlers/view.rb +1 -1
  21. data/lib/otto/route_handlers/base.rb +86 -1
  22. data/lib/otto/route_handlers/class_method.rb +17 -68
  23. data/lib/otto/route_handlers/instance_method.rb +18 -58
  24. data/lib/otto/route_handlers/logic_class.rb +95 -92
  25. data/lib/otto/security/authentication/auth_strategy.rb +2 -2
  26. data/lib/otto/security/authentication/route_auth_wrapper/response_builder.rb +123 -0
  27. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +120 -0
  28. data/lib/otto/security/authentication/route_auth_wrapper/strategy_resolver.rb +69 -0
  29. data/lib/otto/security/authentication/route_auth_wrapper.rb +167 -329
  30. data/lib/otto/security/authentication/strategy_result.rb +9 -9
  31. data/lib/otto/security/authorization_error.rb +1 -1
  32. data/lib/otto/security/config.rb +3 -3
  33. data/lib/otto/security/rate_limiter.rb +7 -3
  34. data/lib/otto/version.rb +1 -1
  35. data/lib/otto.rb +47 -3
  36. metadata +7 -3
  37. data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +0 -15
  38. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +0 -37
@@ -1,71 +1,31 @@
1
1
  # lib/otto/route_handlers/instance_method.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'securerandom'
5
4
 
6
5
  require_relative 'base'
7
6
 
8
7
  class Otto
9
8
  module RouteHandlers
10
9
  # Handler for instance methods (existing Otto pattern)
11
- # Maintains backward compatibility for Controller#action patterns
10
+ # Route syntax: Controller#action
11
+ #
12
+ # Controller instances receive full Rack request/response access:
13
+ # - initialize(request, response) with Rack::Request and Rack::Response
14
+ # - Direct access to sessions, cookies, headers, and the raw env
15
+ #
16
+ # Use this handler for endpoints requiring request-level control (logout,
17
+ # session management, cookie manipulation, custom header handling).
12
18
  class InstanceMethodHandler < BaseHandler
13
- def call(env, extra_params = {})
14
- start_time = Otto::Utils.now_in_μs
15
- req = Rack::Request.new(env)
16
- res = Rack::Response.new
17
-
18
- begin
19
- # Apply the same extensions and processing as original Route#call
20
- setup_request_response(req, res, env, extra_params)
21
-
22
- # Create instance and call method (existing Otto behavior)
23
- instance = target_class.new(req, res)
24
- result = instance.send(route_definition.method_name)
25
-
26
- # Only handle response if response_type is not default
27
- if route_definition.response_type != 'default'
28
- handle_response(result, res, {
29
- instance: instance,
30
- request: req,
31
- })
32
- end
33
- rescue StandardError => e
34
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
35
- # In integrated context, let Otto's centralized error handler manage the response
36
- # In direct testing context, handle errors locally for unit testing
37
- if otto_instance
38
- # Store handler context in env for centralized error handler
39
- handler_name = "#{target_class}##{route_definition.method_name}"
40
- env['otto.handler'] = handler_name
41
- env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
42
-
43
- raise e # Re-raise to let Otto's centralized error handler manage the response
44
- else
45
- # Direct handler testing context - handle errors locally with security improvements
46
- error_id = SecureRandom.hex(8)
47
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
48
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
49
-
50
- res.status = 500
51
- res.headers['content-type'] = 'text/plain'
52
-
53
- if Otto.env?(:dev, :development)
54
- res.write "Server error (ID: #{error_id}). Check logs for details."
55
- else
56
- res.write 'An error occurred. Please try again later.'
57
- end
58
-
59
- # Add security headers if available
60
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
61
- otto_instance.security_config.security_headers.each do |header, value|
62
- res.headers[header] = value
63
- end
64
- end
65
- end
66
- end
67
-
68
- finalize_response(res)
19
+ protected
20
+
21
+ # Invoke the instance method on the target class
22
+ # @param req [Rack::Request] Request object
23
+ # @param res [Rack::Response] Response object
24
+ # @return [Array] [result, context] for handle_response
25
+ def invoke_target(req, res)
26
+ instance = target_class.new(req, res)
27
+ result = instance.send(route_definition.method_name)
28
+ [result, { instance: instance, request: req }]
69
29
  end
70
30
  end
71
31
  end
@@ -1,109 +1,112 @@
1
1
  # lib/otto/route_handlers/logic_class.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- require 'json'
5
- require 'securerandom'
6
4
 
7
5
  require_relative 'base'
8
6
 
9
7
  class Otto
10
8
  module RouteHandlers
11
9
  # Handler for Logic classes (new in Otto Framework Enhancement)
12
- # Handles Logic class routes with the modern RequestContext pattern
13
- # Logic classes use signature: initialize(context, params, locale)
10
+ #
11
+ # Logic classes use a constrained signature: initialize(context, params, locale)
12
+ # - context: The authentication strategy result (user info, session data)
13
+ # - params: Merged request parameters (URL params + body + extra_params)
14
+ # - locale: The locale string from env['otto.locale']
15
+ #
16
+ # IMPORTANT: Logic classes do NOT receive the Rack request or env hash.
17
+ # This is intentional - Logic classes work with clean, authenticated contexts.
18
+ # For endpoints requiring direct request access (sessions, cookies, headers,
19
+ # or logout flows), use controller handlers (Controller#action or Controller.action).
14
20
  class LogicClassHandler < BaseHandler
15
- def call(env, extra_params = {})
16
- start_time = Otto::Utils.now_in_μs
17
- req = Rack::Request.new(env)
18
- res = Rack::Response.new
21
+ protected
19
22
 
23
+ # Invoke Logic class with constrained signature
24
+ # @param req [Rack::Request] Request object
25
+ # @param res [Rack::Response] Response object
26
+ # @return [Array] [result, context] for handle_response
27
+ def invoke_target(req, _res)
28
+ env = req.env
29
+
30
+ # Get strategy result (guaranteed to exist from RouteAuthWrapper)
31
+ strategy_result = env['otto.strategy_result']
32
+
33
+ # Extract params including JSON body parsing
34
+ logic_params = extract_logic_params(req, env)
35
+
36
+ # Get locale
37
+ locale = env['otto.locale'] || 'en'
38
+
39
+ # Instantiate Logic class
40
+ logic = target_class.new(strategy_result, logic_params, locale)
41
+
42
+ # Execute standard Logic class lifecycle
43
+ logic.raise_concerns if logic.respond_to?(:raise_concerns)
44
+
45
+ result = if logic.respond_to?(:process)
46
+ logic.process
47
+ else
48
+ logic.call || logic
49
+ end
50
+
51
+ context = {
52
+ logic_instance: logic,
53
+ request: req,
54
+ status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
55
+ }
56
+
57
+ [result, context]
58
+ end
59
+
60
+ # Extract logic parameters including JSON body parsing
61
+ # @param req [Rack::Request] Request object
62
+ # @param env [Hash] Rack environment
63
+ # @return [Hash] Parameters for Logic class
64
+ def extract_logic_params(req, env)
65
+ # req.params already has extra_params merged and indifferent_params applied
66
+ # by setup_request_response in BaseHandler
67
+ logic_params = req.params.dup
68
+
69
+ # Handle JSON request bodies
70
+ if req.content_type&.include?('application/json') && req.body.size.positive?
71
+ logic_params = parse_json_body(req, env, logic_params)
72
+ end
73
+
74
+ logic_params
75
+ end
76
+
77
+ # Parse JSON request body with error handling
78
+ # @param req [Rack::Request] Request object
79
+ # @param env [Hash] Rack environment
80
+ # @param logic_params [Hash] Current parameters
81
+ # @return [Hash] Parameters with JSON merged (or original if parsing fails)
82
+ def parse_json_body(req, env, logic_params)
20
83
  begin
21
- # Get strategy result (guaranteed to exist from RouteAuthWrapper)
22
- strategy_result = env['otto.strategy_result']
23
-
24
- # Initialize Logic class with new signature: context, params, locale
25
- logic_params = req.params.merge(extra_params)
26
-
27
- # Handle JSON request bodies
28
- if req.content_type&.include?('application/json') && req.body.size.positive?
29
- begin
30
- req.body.rewind
31
- json_data = JSON.parse(req.body.read)
32
- logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
33
- rescue JSON::ParserError => e
34
- # Base context pattern: create once, reuse for correlation
35
- base_context = Otto::LoggingHelpers.request_context(env)
36
-
37
- Otto.structured_log(:error, "JSON parsing error",
38
- base_context.merge(
39
- handler: "#{target_class}#call",
40
- error: e.message,
41
- error_class: e.class.name,
42
- duration: Otto::Utils.now_in_μs - start_time
43
- )
44
- )
45
-
46
- Otto::LoggingHelpers.log_backtrace(e,
47
- base_context.merge(handler: "#{target_class}#call")
48
- )
49
- end
50
- end
51
-
52
- locale = env['otto.locale'] || 'en'
53
-
54
- logic = target_class.new(strategy_result, logic_params, locale)
55
-
56
- # Execute standard Logic class lifecycle
57
- logic.raise_concerns if logic.respond_to?(:raise_concerns)
58
-
59
- result = if logic.respond_to?(:process)
60
- logic.process
61
- else
62
- logic.call || logic
63
- end
64
-
65
- # Handle response with Logic instance context
66
- handle_response(result, res, {
67
- logic_instance: logic,
68
- request: req,
69
- status_code: logic.respond_to?(:status_code) ? logic.status_code : nil,
70
- })
71
- rescue StandardError => e
72
- # Check if we're being called through Otto's integrated context (vs direct handler testing)
73
- # In integrated context, let Otto's centralized error handler manage the response
74
- # In direct testing context, handle errors locally for unit testing
75
- if otto_instance
76
- # Store handler context in env for centralized error handler
77
- handler_name = "#{target_class}#call"
78
- env['otto.handler'] = handler_name
79
- env['otto.handler_duration'] = Otto::Utils.now_in_μs - start_time
80
-
81
- raise e # Re-raise to let Otto's centralized error handler manage the response
82
- else
83
- # Direct handler testing context - handle errors locally with security improvements
84
- error_id = SecureRandom.hex(8)
85
- Otto.logger.error "[#{error_id}] #{e.class}: #{e.message}"
86
- Otto.logger.debug "[#{error_id}] Backtrace: #{e.backtrace.join("\n")}" if Otto.debug
87
-
88
- res.status = 500
89
- res.headers['content-type'] = 'text/plain'
90
-
91
- if Otto.env?(:dev, :development)
92
- res.write "Server error (ID: #{error_id}). Check logs for details."
93
- else
94
- res.write 'An error occurred. Please try again later.'
95
- end
96
-
97
- # Add security headers if available
98
- if otto_instance.respond_to?(:security_config) && otto_instance.security_config
99
- otto_instance.security_config.security_headers.each do |header, value|
100
- res.headers[header] = value
101
- end
102
- end
103
- end
84
+ req.body.rewind
85
+ json_data = JSON.parse(req.body.read)
86
+ logic_params = logic_params.merge(json_data) if json_data.is_a?(Hash)
87
+ rescue JSON::ParserError => e
88
+ # Base context pattern: create once, reuse for correlation
89
+ log_context = Otto::LoggingHelpers.request_context(env)
90
+
91
+ Otto.structured_log(:error, 'JSON parsing error',
92
+ log_context.merge(
93
+ handler: handler_name,
94
+ error: e.message,
95
+ error_class: e.class.name,
96
+ duration: Otto::Utils.now_in_μs - @start_time
97
+ ))
98
+
99
+ Otto::LoggingHelpers.log_backtrace(e,
100
+ log_context.merge(handler: handler_name))
104
101
  end
105
102
 
106
- res.finish
103
+ logic_params
104
+ end
105
+
106
+ # Format handler name for Logic routes
107
+ # @return [String] Handler name in format "ClassName#call"
108
+ def handler_name
109
+ "#{target_class.name}#call"
107
110
  end
108
111
  end
109
112
  end
@@ -1,7 +1,7 @@
1
1
  # lib/otto/security/authentication/auth_strategy.rb
2
2
  #
3
3
  # frozen_string_literal: true
4
- #
4
+
5
5
  # Base class for all authentication strategies in Otto framework
6
6
  # Provides pluggable authentication patterns that can be customized per application
7
7
 
@@ -33,7 +33,7 @@ class Otto
33
33
  user: user,
34
34
  auth_method: auth_method || self.class.name.split('::').last,
35
35
  metadata: metadata,
36
- strategy_name: nil # Will be set by RouteAuthWrapper
36
+ strategy_name: nil # Will be set by RouteAuthWrapper
37
37
  )
38
38
  end
39
39
 
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Builds HTTP error responses for authentication/authorization failures
8
+ #
9
+ # Handles content negotiation (JSON vs HTML) and applies security headers.
10
+ # Route's declared response_type takes precedence over Accept header.
11
+ #
12
+ # @example
13
+ # builder = ResponseBuilder.new(route_definition, auth_config, security_config)
14
+ # response = builder.unauthorized(env, "Invalid token")
15
+ # response = builder.forbidden(env, "Admin role required")
16
+ # response = builder.auth_failure(env, auth_failure_result)
17
+ #
18
+ class ResponseBuilder
19
+ # @param route_definition [RouteDefinition] Route with response_type info
20
+ # @param auth_config [Hash] Auth config with :login_path for HTML redirects
21
+ # @param security_config [SecurityConfig, nil] Optional security config for headers
22
+ def initialize(route_definition, auth_config, security_config = nil)
23
+ @route_definition = route_definition
24
+ @auth_config = auth_config
25
+ @security_config = security_config
26
+ end
27
+
28
+ # Generate response for authentication failure
29
+ #
30
+ # @param env [Hash] Rack environment
31
+ # @param result [AuthFailure] Failure result from strategy
32
+ # @return [Array] Rack response array
33
+ def auth_failure(env, result)
34
+ wants_json?(env) ? json_auth_error(result) : html_auth_error(result)
35
+ end
36
+
37
+ # Generate 401 Unauthorized response
38
+ #
39
+ # @param env [Hash] Rack environment
40
+ # @param message [String] Error message
41
+ # @return [Array] Rack response array
42
+ def unauthorized(env, message)
43
+ if wants_json?(env)
44
+ json_response(401, error: message)
45
+ else
46
+ text_response(401, message)
47
+ end
48
+ end
49
+
50
+ # Generate 403 Forbidden response
51
+ #
52
+ # @param env [Hash] Rack environment
53
+ # @param message [String] Error message
54
+ # @return [Array] Rack response array
55
+ def forbidden(env, message)
56
+ if wants_json?(env)
57
+ json_response(403, error: 'Forbidden', message: message)
58
+ else
59
+ text_response(403, message)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Determine if response should be JSON based on route config and Accept header
66
+ #
67
+ # Route's declared response type takes precedence over Accept header.
68
+ # This ensures API routes (response=json) always get JSON errors.
69
+ #
70
+ # @param env [Hash] Rack environment
71
+ # @return [Boolean] true if response should be JSON
72
+ def wants_json?(env)
73
+ return true if @route_definition.response_type == 'json'
74
+
75
+ accept_header = env['HTTP_ACCEPT'] || ''
76
+ accept_header.include?('application/json')
77
+ end
78
+
79
+ # Generate JSON 401 response for auth failure
80
+ def json_auth_error(result)
81
+ json_response(401,
82
+ error: 'Authentication Required',
83
+ message: result.failure_reason || 'Not authenticated',
84
+ timestamp: Time.now.to_i)
85
+ end
86
+
87
+ # Generate HTML 401 response (redirect to login)
88
+ def html_auth_error(_result)
89
+ login_path = @auth_config[:login_path] || '/signin'
90
+ headers = { 'location' => login_path }
91
+ merge_security_headers!(headers)
92
+ [302, headers, ["Redirecting to #{login_path}"]]
93
+ end
94
+
95
+ # Build a JSON response with security headers
96
+ def json_response(status, body_hash)
97
+ body = body_hash.to_json
98
+ headers = {
99
+ 'content-type' => 'application/json',
100
+ 'content-length' => body.bytesize.to_s,
101
+ }
102
+ merge_security_headers!(headers)
103
+ [status, headers, [body]]
104
+ end
105
+
106
+ # Build a plain text response with security headers
107
+ def text_response(status, message)
108
+ headers = { 'content-type' => 'text/plain' }
109
+ merge_security_headers!(headers)
110
+ [status, headers, [message]]
111
+ end
112
+
113
+ # Merge security headers into response headers
114
+ def merge_security_headers!(headers)
115
+ return unless @security_config
116
+
117
+ headers.merge!(@security_config.security_headers)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Handles Layer 1 (route-level) role-based authorization
8
+ #
9
+ # Extracts user roles from authentication results and checks against
10
+ # route requirements using OR logic (user needs ANY of the required roles).
11
+ #
12
+ # @example
13
+ # authorizer = RoleAuthorization.new(route_definition)
14
+ # authorizer.check!(strategy_result, env) # raises or returns true
15
+ #
16
+ # @note This is Layer 1 authorization only. Layer 2 (resource-level)
17
+ # authorization should be handled in Logic classes via raise_concerns.
18
+ #
19
+ class RoleAuthorization
20
+ # @param route_definition [RouteDefinition] Route with role requirements
21
+ def initialize(route_definition)
22
+ @route_definition = route_definition
23
+ end
24
+
25
+ # Check if authentication result satisfies role requirements
26
+ #
27
+ # @param result [StrategyResult] Authentication result
28
+ # @param env [Hash] Rack environment (for logging)
29
+ # @return [true] if authorized
30
+ # @return [Hash] failure info if not authorized: { authorized: false, required: [...], actual: [...] }
31
+ def check(result, env)
32
+ role_requirements = @route_definition.role_requirements
33
+ return true if role_requirements.empty?
34
+
35
+ user_roles = extract_roles(result)
36
+
37
+ # OR logic: user needs ANY of the required roles
38
+ if (user_roles & role_requirements).any?
39
+ log_success(env, role_requirements, user_roles)
40
+ true
41
+ else
42
+ log_failure(env, role_requirements, user_roles, result)
43
+ {
44
+ authorized: false,
45
+ required: role_requirements,
46
+ actual: user_roles,
47
+ }
48
+ end
49
+ end
50
+
51
+ # Check authorization, returning boolean
52
+ #
53
+ # @param result [StrategyResult] Authentication result
54
+ # @return [Boolean] true if authorized
55
+ def authorized?(result)
56
+ role_requirements = @route_definition.role_requirements
57
+ return true if role_requirements.empty?
58
+
59
+ user_roles = extract_roles(result)
60
+ (user_roles & role_requirements).any?
61
+ end
62
+
63
+ # Get the role requirements for error messages
64
+ #
65
+ # @return [Array<String>] Required roles
66
+ def requirements
67
+ @route_definition.role_requirements
68
+ end
69
+
70
+ private
71
+
72
+ # Extract user roles from authentication result
73
+ #
74
+ # Supports multiple role sources in order of precedence:
75
+ # 1. result.user_roles (Array)
76
+ # 2. result.user[:roles] (Array)
77
+ # 3. result.user['roles'] (Array)
78
+ # 4. result.metadata[:user_roles] (Array)
79
+ #
80
+ # @param result [StrategyResult] Authentication result
81
+ # @return [Array<String>] Array of role strings
82
+ def extract_roles(result)
83
+ # Try direct user_roles accessor (e.g., from RoleStrategy)
84
+ return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
85
+
86
+ # Try user hash/object with roles
87
+ if result.user
88
+ roles = result.user[:roles] || result.user['roles']
89
+ return Array(roles) if roles
90
+ end
91
+
92
+ # Try metadata
93
+ return Array(result.metadata[:user_roles]) if result.metadata && result.metadata[:user_roles]
94
+
95
+ # No roles found
96
+ []
97
+ end
98
+
99
+ def log_success(env, required_roles, user_roles)
100
+ Otto.structured_log(:debug, 'Role authorization succeeded',
101
+ Otto::LoggingHelpers.request_context(env).merge(
102
+ required_roles: required_roles,
103
+ user_roles: user_roles,
104
+ matched_roles: user_roles & required_roles
105
+ ))
106
+ end
107
+
108
+ def log_failure(env, required_roles, user_roles, result)
109
+ Otto.structured_log(:warn, 'Role authorization failed',
110
+ Otto::LoggingHelpers.request_context(env).merge(
111
+ required_roles: required_roles,
112
+ user_roles: user_roles,
113
+ user_id: result.user_id
114
+ ))
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Authentication
6
+ module RouteAuthWrapperComponents
7
+ # Resolves authentication strategy names to strategy instances
8
+ #
9
+ # Handles strategy lookup with caching and pattern matching:
10
+ # - Exact match: 'authenticated' → looks up auth_config[:auth_strategies]['authenticated']
11
+ # - Prefix match: 'custom:value' → looks up 'custom' strategy
12
+ #
13
+ # Results are cached to avoid repeated lookups for the same requirement.
14
+ #
15
+ # @example
16
+ # resolver = StrategyResolver.new(auth_config)
17
+ # strategy, name = resolver.resolve('session')
18
+ # strategy, name = resolver.resolve('oauth:google') # prefix match
19
+ #
20
+ class StrategyResolver
21
+ # @param auth_config [Hash] Auth configuration with :auth_strategies key
22
+ def initialize(auth_config)
23
+ @auth_config = auth_config
24
+ @cache = {}
25
+ end
26
+
27
+ # Resolve a requirement string to a strategy instance
28
+ #
29
+ # @param requirement [String] Auth requirement from route (e.g., 'session', 'oauth:google')
30
+ # @return [Array<AuthStrategy, String>, Array<nil, nil>] Tuple of [strategy, name] or [nil, nil]
31
+ def resolve(requirement)
32
+ return [nil, nil] unless @auth_config && @auth_config[:auth_strategies]
33
+
34
+ # Check cache first
35
+ return @cache[requirement] if @cache.key?(requirement)
36
+
37
+ result = find_strategy(requirement)
38
+ @cache[requirement] = result
39
+ result
40
+ end
41
+
42
+ # Clear the strategy cache
43
+ def clear_cache
44
+ @cache.clear
45
+ end
46
+
47
+ private
48
+
49
+ def find_strategy(requirement)
50
+ strategies = @auth_config[:auth_strategies]
51
+
52
+ # Try exact match first - highest priority
53
+ strategy = strategies[requirement]
54
+ return [strategy, requirement] if strategy
55
+
56
+ # For colon-separated requirements like "custom:value", try prefix match
57
+ if requirement.include?(':')
58
+ prefix = requirement.split(':', 2).first
59
+ prefix_strategy = strategies[prefix]
60
+ return [prefix_strategy, prefix] if prefix_strategy
61
+ end
62
+
63
+ [nil, nil]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end