otto 2.9.0 → 2.11.0

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +11 -8
  4. data/.github/workflows/claude-code-review.yml +38 -13
  5. data/.github/workflows/claude.yml +10 -8
  6. data/.github/workflows/code-smells.yml +5 -5
  7. data/.github/workflows/release-gem.yml +2 -2
  8. data/.github/workflows/ruby-lint.yml +3 -3
  9. data/.github/workflows/yardoc.yml +5 -5
  10. data/.gitignore +1 -5
  11. data/.rubocop_todo.yml +8 -5
  12. data/AGENTS.md +40 -1
  13. data/CHANGELOG.rst +235 -0
  14. data/Gemfile +3 -3
  15. data/Gemfile.lock +11 -15
  16. data/README.md +75 -37
  17. data/docs/README.md +166 -0
  18. data/docs/adr/README.md +16 -0
  19. data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
  20. data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
  21. data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
  22. data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
  23. data/docs/guides/authentication.md +377 -0
  24. data/docs/guides/caddy-tls.md +205 -0
  25. data/docs/guides/configuration_freezing.md +157 -0
  26. data/docs/guides/enrichment.md +161 -0
  27. data/docs/guides/forwarded-authority.md +249 -0
  28. data/docs/guides/geo-country.md +168 -0
  29. data/docs/guides/ip_privacy.md +39 -0
  30. data/docs/guides/ipaddr-encoding-quirk.md +56 -0
  31. data/docs/guides/mcp.md +282 -0
  32. data/docs/guides/privacy.md +193 -0
  33. data/docs/guides/routing.md +293 -0
  34. data/docs/guides/structured_logging.md +281 -0
  35. data/docs/guides/testing-guide.md +391 -0
  36. data/docs/maintainers/github-actions.md +41 -0
  37. data/docs/maintainers/investigations/.gitignore +2 -0
  38. data/docs/migrating/v2.0.0.md +337 -0
  39. data/docs/reference/authentication.md +290 -0
  40. data/docs/reference/route-syntax.md +181 -0
  41. data/docs/reference/runtime-and-dependency-security.md +86 -0
  42. data/examples/advanced_routes/README.md +43 -57
  43. data/examples/authentication_strategies/README.md +37 -196
  44. data/examples/basic/README.md +24 -39
  45. data/examples/basic/config.ru +0 -1
  46. data/examples/caddy_tls_demo/README.md +8 -2
  47. data/examples/lambda_handlers/README.md +11 -2
  48. data/examples/mcp_demo/README.md +75 -161
  49. data/examples/mcp_demo/config.ru +1 -0
  50. data/examples/security_features/README.md +36 -234
  51. data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
  52. data/lib/otto/core/configuration.rb +40 -23
  53. data/lib/otto/core/error_handler.rb +40 -2
  54. data/lib/otto/core/file_safety.rb +105 -31
  55. data/lib/otto/core/middleware_stack.rb +36 -36
  56. data/lib/otto/core/router.rb +94 -18
  57. data/lib/otto/core/static_mounts.rb +172 -0
  58. data/lib/otto/core.rb +1 -0
  59. data/lib/otto/env_keys.rb +20 -2
  60. data/lib/otto/mcp/auth/token.rb +10 -4
  61. data/lib/otto/mcp/core.rb +23 -5
  62. data/lib/otto/mcp/endpoint.rb +41 -0
  63. data/lib/otto/mcp/errors.rb +15 -0
  64. data/lib/otto/mcp/options.rb +292 -0
  65. data/lib/otto/mcp/protocol.rb +52 -22
  66. data/lib/otto/mcp/rate_limiting.rb +175 -97
  67. data/lib/otto/mcp/registry.rb +14 -14
  68. data/lib/otto/mcp/schema_validation.rb +20 -12
  69. data/lib/otto/mcp/server.rb +131 -32
  70. data/lib/otto/optional_dependency.rb +57 -0
  71. data/lib/otto/privacy/config.rb +10 -8
  72. data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
  73. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
  74. data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
  75. data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
  76. data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
  77. data/lib/otto/security/authentication/strategy_result.rb +58 -28
  78. data/lib/otto/security/config.rb +328 -14
  79. data/lib/otto/security/configurator.rb +36 -6
  80. data/lib/otto/security/core.rb +19 -1
  81. data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
  82. data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
  83. data/lib/otto/security/rate_limiter.rb +81 -46
  84. data/lib/otto/static.rb +38 -0
  85. data/lib/otto/utils.rb +50 -0
  86. data/lib/otto/version.rb +1 -1
  87. data/lib/otto.rb +130 -13
  88. data/otto.gemspec +0 -2
  89. metadata +32 -41
  90. data/docs/.gitignore +0 -10
  91. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
  92. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
  93. data/docs/enrichment.md +0 -128
  94. data/docs/geo-country.md +0 -181
  95. data/docs/ipaddr-encoding-quirk.md +0 -34
  96. data/docs/migrating/v2.0.0-pre1.md +0 -276
  97. data/docs/migrating/v2.0.0-pre2.md +0 -338
  98. data/docs/modern-authentication-authorization-landscape.md +0 -558
  99. data/docs/multi-strategy-authentication-design.md +0 -1401
  100. data/docs/reverse-proxy-network-services.md +0 -371
@@ -8,6 +8,7 @@ require_relative 'route_parser'
8
8
  require_relative 'auth/token'
9
9
  require_relative 'schema_validation'
10
10
  require_relative 'rate_limiting'
11
+ require_relative 'options'
11
12
  require_relative '../security/constant_resolver'
12
13
 
13
14
  class Otto
@@ -16,18 +17,67 @@ class Otto
16
17
  class Server
17
18
  attr_reader :protocol, :otto_instance
18
19
 
20
+ # Normalize raw options into the canonical MCP option hash.
21
+ #
22
+ # @param opts [Hash] raw options
23
+ # @param scope [Symbol] :explicit (strict, for #enable_mcp!) or
24
+ # :constructor (ignores non-MCP keys, for Otto.new)
25
+ # @see Otto::MCP::Options.normalize
26
+ def self.normalize_options(opts = {}, scope = :explicit)
27
+ Otto::MCP::Options.normalize(opts, scope)
28
+ end
29
+
19
30
  def initialize(otto_instance)
20
31
  @otto_instance = otto_instance
21
32
  @protocol = Protocol.new(otto_instance)
22
33
  @enabled = false
23
34
  end
24
35
 
36
+ # Warning emitted when the MCP HTTP endpoint is exposed with no token
37
+ # authentication. Unconditional (not gated on Otto.debug): an
38
+ # unauthenticated MCP endpoint lets any caller invoke every registered
39
+ # tool, so it must be visible in normal boot output.
40
+ UNAUTHENTICATED_WARNING = <<~MSG.gsub(/\s+/, ' ').strip.freeze
41
+ [MCP] HTTP endpoint %s is enabled without authentication:
42
+ any caller can list and invoke MCP tools and resources.
43
+ Pass auth_tokens: ['<token>'] to require a bearer token, or
44
+ allow_unauthenticated: true to acknowledge this intentionally.
45
+ MSG
46
+
47
+ # Enable the MCP server.
48
+ #
49
+ # Enabling is one-shot. Each call appends a route, an endpoint-setting
50
+ # proc and the MCP middleware to the Otto instance without removing the
51
+ # previous set, so a second call with a different endpoint would leave
52
+ # the first endpoint routed but guarded by nothing (the auth middleware
53
+ # only matches the newest endpoint). Rather than try to unwind that,
54
+ # a second call raises.
55
+ #
56
+ # @param options [Hash] canonical or aliased options; normalized via
57
+ # {.normalize_options}, so both the canonical keys (:http_endpoint,
58
+ # :auth_tokens, ...) and their mcp_-prefixed spellings
59
+ # (:mcp_endpoint, :mcp_auth_tokens, ...) are accepted.
60
+ # @raise [ArgumentError] if the server is already enabled
25
61
  def enable!(options = {})
26
- @enabled = true
27
- @http_endpoint = options.fetch(:http_endpoint, '/_mcp')
28
- @auth_tokens = options[:auth_tokens] || []
29
- @enable_validation = options.fetch(:enable_validation, true)
30
- @enable_rate_limiting = options.fetch(:enable_rate_limiting, true)
62
+ if @enabled
63
+ raise ArgumentError,
64
+ "MCP server is already enabled on #{@http_endpoint}; pass all MCP options " \
65
+ 'in a single Otto.new or enable_mcp! call'
66
+ end
67
+
68
+ options = self.class.normalize_options(options)
69
+
70
+ Validator.ensure_available! if options[:enable_validation]
71
+ Otto::Security::RateLimiting.ensure_available! if options[:enable_rate_limiting]
72
+
73
+ @enabled = true
74
+ @http_endpoint = options[:http_endpoint]
75
+ @auth_tokens = options[:auth_tokens]
76
+ @enable_validation = options[:enable_validation]
77
+ @enable_rate_limiting = options[:enable_rate_limiting]
78
+ @allow_unauthenticated = options[:allow_unauthenticated]
79
+
80
+ apply_rate_limits(options)
31
81
 
32
82
  # Configure middleware
33
83
  configure_middleware(options)
@@ -35,6 +85,8 @@ class Otto
35
85
  # Add MCP endpoint route to Otto
36
86
  add_mcp_endpoint_route
37
87
 
88
+ warn_if_unauthenticated!
89
+
38
90
  Otto.logger.info "[MCP] Server enabled with HTTP endpoint: #{@http_endpoint}" if Otto.debug
39
91
  end
40
92
 
@@ -53,42 +105,82 @@ class Otto
53
105
 
54
106
  private
55
107
 
108
+ # Publish the per-minute limits under the keys RateLimitMiddleware /
109
+ # RateLimiter.configure_rack_attack! already read, via Otto's sanctioned
110
+ # rate-limiting configuration entry point. Before this, the values passed
111
+ # to enable! were dead and the hardcoded 60/20 always won.
112
+ #
113
+ # The endpoint travels with them. Rack::Attack is mounted by the host app
114
+ # OUTSIDE Otto and runs before any of Otto's middleware, so the
115
+ # env['otto.mcp_http_endpoint'] set by add_mcp_endpoint_route is not yet
116
+ # present when the throttles are evaluated; without this, a custom
117
+ # endpoint was compared against the '/_mcp' fallback and never throttled.
118
+ def apply_rate_limits(options)
119
+ return unless @enable_rate_limiting
120
+
121
+ @otto_instance.configure_rate_limiting(
122
+ mcp_http_endpoint: @http_endpoint,
123
+ mcp_requests_per_minute: options[:requests_per_minute],
124
+ tool_calls_per_minute: options[:tools_per_minute]
125
+ )
126
+ end
127
+
128
+ def warn_if_unauthenticated!
129
+ return if @auth_tokens.any? || @allow_unauthenticated
130
+
131
+ Otto.logger.warn format(UNAUTHENTICATED_WARNING, @http_endpoint)
132
+ end
133
+
56
134
  def configure_middleware(_options)
57
- # Configure middleware in security-optimal order using explicit positioning:
58
- # 1. Rate limiting (reject excessive requests early) - position: :first
59
- # 2. Authentication (validate credentials before parsing) - default append
60
- # 3. Validation (expensive JSON schema validation last) - position: :last
135
+ # Target EXECUTION order (outermost first, i.e. what a request meets in
136
+ # turn), security-optimal:
137
+ # 1. Rate limiting — shed excessive load before spending any work
138
+ # 2. Authentication reject anonymous callers before parsing bodies
139
+ # 3. Validation — expensive JSON schema check only on requests
140
+ # that already proved themselves
141
+ #
142
+ # MiddlewareStack stores entries in the REVERSE of execution order
143
+ # (#wrap folds with reduce, so a later array entry is a further-out
144
+ # wrapper). Registration therefore runs innermost-first: validation is
145
+ # pinned :innermost, then auth is appended, then rate limiting. The
146
+ # previous code read the positions as execution order and produced the
147
+ # exact inverse — validation ran ahead of auth, so unauthenticated
148
+ # callers reached the schema validator.
61
149
 
62
150
  middleware = @otto_instance.instance_variable_get(:@middleware)
63
151
 
64
- # Configure rate limiting first (explicit position for clarity)
65
- if @enable_rate_limiting
152
+ # Innermost (last to execute): schema validation, closest to the app.
153
+ if @enable_validation
66
154
  middleware.add_with_position(
67
- Otto::MCP::RateLimitMiddleware,
68
- @otto_instance.security_config,
69
- position: :first
155
+ Otto::MCP::SchemaValidationMiddleware,
156
+ position: :innermost
70
157
  )
71
- Otto.logger.debug '[MCP] Rate limiting enabled (position: first)' if Otto.debug
158
+ Otto.logger.debug '[MCP] Schema validation enabled (executes last)' if Otto.debug
72
159
  end
73
160
 
74
- # Configure authentication second (default append order)
161
+ # Middle: authentication, outside validation and inside rate limiting.
75
162
  if @auth_tokens.any?
76
163
  @auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
77
164
  @otto_instance.security_config.mcp_auth = @auth
78
- @otto_instance.use Otto::MCP::Auth::TokenMiddleware
79
- Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
165
+ # Pass security_config explicitly: TokenMiddleware is not in
166
+ # MiddlewareStack#middleware_needs_config?, so #wrap would build it
167
+ # with a nil config and — now that the middleware fails closed —
168
+ # reject every request, valid token included.
169
+ @otto_instance.use Otto::MCP::Auth::TokenMiddleware, @otto_instance.security_config
170
+ Otto.logger.debug '[MCP] Token authentication enabled (executes after rate limiting)' if Otto.debug
80
171
  end
81
172
 
82
- # Configure validation last (explicit position for clarity)
83
- return unless @enable_validation
84
-
85
- middleware.add_with_position(
86
- Otto::MCP::SchemaValidationMiddleware,
87
- position: :last
88
- )
89
- Otto.logger.debug '[MCP] Schema validation enabled (position: last)' if Otto.debug
173
+ # Outermost of the three (first to execute): rate limiting.
174
+ if @enable_rate_limiting
175
+ middleware.add_with_position(
176
+ Otto::MCP::RateLimitMiddleware,
177
+ @otto_instance.security_config,
178
+ position: :last
179
+ )
180
+ Otto.logger.debug '[MCP] Rate limiting enabled (executes first)' if Otto.debug
181
+ end
90
182
 
91
- # Validate middleware order (should pass with explicit positioning)
183
+ # Validate execution order (should pass with the positioning above).
92
184
  warnings = middleware.validate_mcp_middleware_order
93
185
  warnings.each { |warning| Otto.logger.warn warning }
94
186
  end
@@ -102,13 +194,20 @@ class Otto
102
194
  @otto_instance.routes[:POST] ||= []
103
195
  @otto_instance.routes[:POST] << mcp_route
104
196
 
105
- @otto_instance.routes_literal[:POST] ||= {}
106
- @otto_instance.routes_literal[:POST][@http_endpoint] = mcp_route
107
-
108
- # Ensure env carries endpoint for middlewares
197
+ # Keyed by the normalized path, as the router keys every literal route
198
+ # and normalizes PATH_INFO before the lookup: a configured trailing
199
+ # slash ('/a/') or the root ('/') otherwise registers a key the lookup
200
+ # can never hit, while Otto::MCP.endpoint_path? still claims the path.
201
+ @otto_instance.routes_literal[:POST] ||= {}
202
+ @otto_instance.routes_literal[:POST][Otto::Utils.normalize_path(@http_endpoint)] = mcp_route
203
+
204
+ # Ensure env carries endpoint for middlewares. Close over a local copy:
205
+ # the proc must keep announcing the endpoint it was registered for even
206
+ # if the ivar is ever reassigned.
207
+ endpoint = @http_endpoint
109
208
  @otto_instance.use proc { |app|
110
209
  lambda { |env|
111
- env['otto.mcp_http_endpoint'] = @http_endpoint
210
+ env['otto.mcp_http_endpoint'] = endpoint
112
211
  app.call(env)
113
212
  }
114
213
  }
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+
5
+ class Otto
6
+ # Raised when an explicitly enabled feature cannot load its optional gem.
7
+ class OptionalDependencyError < ArgumentError; end
8
+
9
+ # Loads optional gems against the compatibility ranges Otto supports.
10
+ module OptionalDependency
11
+ module_function
12
+
13
+ def require!(gem_name, requirement, require_path:, feature:, alternative: nil)
14
+ version_requirement = Gem::Requirement.new(requirement)
15
+ guidance = "Add `gem '#{gem_name}', '#{requirement}'` to your Gemfile."
16
+ guidance = "#{guidance} #{alternative}" if alternative
17
+ installed_specs = Gem::Specification.find_all_by_name(gem_name)
18
+ active_spec = Gem.loaded_specs[gem_name]
19
+ compatible_spec = if active_spec && version_requirement.satisfied_by?(active_spec.version)
20
+ active_spec
21
+ else
22
+ installed_specs
23
+ .select { |spec| version_requirement.satisfied_by?(spec.version) }
24
+ .max_by(&:version)
25
+ end
26
+
27
+ unless compatible_spec
28
+ installed = installed_specs.map(&:version).sort.join(', ')
29
+ reason = installed.empty? ? 'it is not installed' : "installed version(s) #{installed} are incompatible"
30
+ raise OptionalDependencyError,
31
+ "#{feature} requires optional dependency '#{gem_name}' (#{version_requirement}), but #{reason}. #{guidance}"
32
+ end
33
+
34
+ load_error = begin
35
+ compatible_spec.activate
36
+ require require_path
37
+ nil
38
+ # Convert loader failures into a feature-specific configuration error.
39
+ # Gem::LoadError (activation conflicts) is a LoadError subclass, so one
40
+ # rescue covers both activation and require failures.
41
+ rescue LoadError => e
42
+ e
43
+ end
44
+
45
+ raise_load_error!(feature, gem_name, version_requirement, guidance, load_error) if load_error
46
+
47
+ compatible_spec
48
+ end
49
+
50
+ def raise_load_error!(feature, gem_name, requirement, guidance, error)
51
+ raise OptionalDependencyError,
52
+ "#{feature} requires optional dependency '#{gem_name}' (#{requirement}), but it could not be " \
53
+ "loaded: #{error.message}. #{guidance}",
54
+ cause: error
55
+ end
56
+ end
57
+ end
@@ -9,6 +9,7 @@ require 'digest'
9
9
  require 'concurrent'
10
10
 
11
11
  require_relative '../core/freezable'
12
+ require_relative '../optional_dependency'
12
13
 
13
14
  class Otto
14
15
  module Privacy
@@ -31,6 +32,8 @@ class Otto
31
32
  class Config
32
33
  include Otto::Core::Freezable
33
34
 
35
+ MAXMIND_DB_REQUIREMENT = '~> 1.2'
36
+
34
37
  # Named privacy profiles: validated presets over the individual knobs,
35
38
  # so a deployment's observability posture is declared in one reviewable
36
39
  # word instead of inferred from knob combinations.
@@ -540,14 +543,13 @@ class Otto
540
543
  def build_maxmind_reader(path, option_name: 'geo_db_path')
541
544
  raise ArgumentError, "#{option_name} is not readable: #{path.inspect}" unless File.readable?(path)
542
545
 
543
- begin
544
- require 'maxmind/db'
545
- rescue LoadError
546
- raise ArgumentError,
547
- "#{option_name} is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
548
- "Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
549
- "configure_ip_privacy(#{option_name.sub('_path', '_reader')}: ...)."
550
- end
546
+ Otto::OptionalDependency.require!(
547
+ 'maxmind-db',
548
+ MAXMIND_DB_REQUIREMENT,
549
+ require_path: 'maxmind/db',
550
+ feature: "#{option_name} database loading",
551
+ alternative: "Or inject a reader with `#{option_name.sub('_path', '_reader')}:`."
552
+ )
551
553
 
552
554
  begin
553
555
  MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
@@ -73,20 +73,37 @@ class Otto
73
73
  #
74
74
  # Supports multiple role sources in order of precedence:
75
75
  # 1. result.user_roles (Array)
76
- # 2. result.user[:roles] (Array)
77
- # 3. result.user['roles'] (Array)
76
+ # 2. result.user[:roles] / result.user['roles'] (Hash user)
77
+ # 3. result.user.roles, then result.user.role (object-backed user:
78
+ # ORM model, PORO, Data/Struct); #roles must return role names
78
79
  # 4. result.metadata[:user_roles] (Array)
79
80
  #
81
+ # A user object that is neither a Hash nor responds to `#roles`/`#role`
82
+ # contributes no roles rather than raising, so authorization yields a
83
+ # deny result instead of a NoMethodError.
84
+ #
80
85
  # @param result [StrategyResult] Authentication result
81
86
  # @return [Array<String>] Array of role strings
82
87
  def extract_roles(result)
83
88
  # Try direct user_roles accessor (e.g., from RoleStrategy)
84
89
  return Array(result.user_roles) if result.respond_to?(:user_roles) && result.user_roles
85
90
 
86
- # Try user hash/object with roles
87
- if result.user
88
- roles = result.user[:roles] || result.user['roles']
91
+ # Hash access first (unchanged behaviour), then object-backed users
92
+ user = result.user
93
+ if user.is_a?(Hash)
94
+ roles = user[:roles] || user['roles']
89
95
  return Array(roles) if roles
96
+ else
97
+ # Same fallback as StrategyResult#roles: an empty or nil #roles
98
+ # falls through to a singular #role.
99
+ if user.respond_to?(:roles)
100
+ roles = Array(user.roles).map(&:to_s)
101
+ return roles unless roles.empty?
102
+ end
103
+ if user.respond_to?(:role)
104
+ role = user.role
105
+ return [role.to_s] if role
106
+ end
90
107
  end
91
108
 
92
109
  # Try metadata
@@ -3,50 +3,213 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../auth_strategy'
6
+ require 'digest'
6
7
  require 'rack/utils'
7
8
 
8
9
  class Otto
9
10
  module Security
10
11
  module Authentication
11
12
  module Strategies
12
- # API key authentication strategy
13
+ # API key authentication strategy.
14
+ #
15
+ # Accepts exactly one key source: a static list (`api_keys:`), a
16
+ # callable (`resolver:`), or a block. A resolver receives the presented
17
+ # key (a non-empty String, never env) and returns the account behind it,
18
+ # or nil/false when the key is unknown. Any other return value, including
19
+ # an empty container, counts as a match: an ORM relation from `where`,
20
+ # an empty Array from `select`, or `{}` from a cache miss is truthy and
21
+ # authenticates the caller. Return exactly one record (`find_by`,
22
+ # `first`) or nil. The strategy never branches on mode: a static list is
23
+ # wrapped in a resolver internally.
24
+ #
25
+ # Fails closed: a strategy with no source, or a static list with no
26
+ # non-empty key, is a misconfiguration and raises ArgumentError at
27
+ # construction rather than authenticating every caller. Only a truthy
28
+ # resolver return (for a static list: a constant-time match) grants
29
+ # success.
30
+ #
31
+ # A credential that was presented and rejected, including a non-String
32
+ # credential such as an array query parameter, fails TERMINALLY, so a bad
33
+ # key halts the strategy chain instead of falling through to a later
34
+ # anonymous-capable strategy. A missing credential fails non-terminally.
35
+ # Blank and non-String credentials are rejected before the resolver runs.
36
+ #
37
+ # Exceptions raised by a resolver propagate. A database outage must
38
+ # surface as an error, not as a silent 401 and never as success.
39
+ #
40
+ # Timing: the static list is compared in constant time against every
41
+ # configured key. Both sides are reduced to fixed-width SHA-256 digests
42
+ # first, so the comparison never short-circuits on a length mismatch and
43
+ # the lengths of configured keys are not observable. A black-box lookup
44
+ # cannot be made constant-time by the strategy; the documented pattern
45
+ # is to store SHA-256 digests and look up by {APIKeyStrategy.digest},
46
+ # which is constant-time by construction and keeps raw keys out of the
47
+ # database.
48
+ #
49
+ # The strategy never places the raw key in the result itself: the only
50
+ # strategy-generated field derived from the key is a short SHA-256
51
+ # fingerprint. With a resolver, `user` is whatever the resolver returns,
52
+ # verbatim; the result is stored in env['otto.strategy_result'] and
53
+ # exposed to handlers, so anything the application serializes or logs
54
+ # from it carries `user`. It is the resolver's responsibility not to
55
+ # return an object that holds the raw key: return the account, not the
56
+ # ApiKey row that stores the key, and store digests. A resolver that
57
+ # returns the presented key String itself as the user raises
58
+ # ArgumentError.
59
+ #
60
+ # The query/form parameter path is opt-in (`param_name:`), because keys in
61
+ # URLs are recorded by access logs, proxies, and browser history.
62
+ #
63
+ # Scope: this is a small static-allowlist authenticator shipped as a
64
+ # low-dependency convenience and reference implementation. It has no
65
+ # native support for runtime addition or revocation, expiration, roles
66
+ # or scopes, key metadata, quotas, a management API, audit history, or
67
+ # hashed verifier storage. The resolver form delegates those concerns
68
+ # to the application's key store; see docs/guides/authentication.md.
69
+ #
70
+ # @example Static list, header only (recommended)
71
+ # APIKeyStrategy.new(api_keys: ['secret123'])
72
+ # @example Block resolver looking up a stored digest
73
+ # APIKeyStrategy.new do |presented_key|
74
+ # ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.account
75
+ # end
76
+ # @example Callable resolver (anything responding to #call)
77
+ # APIKeyStrategy.new(resolver: repo.method(:find_by_key))
78
+ # @example Also accept ?api_key= (logged in URLs; prefer the header)
79
+ # APIKeyStrategy.new(api_keys: ['secret123'], param_name: 'api_key')
13
80
  class APIKeyStrategy < AuthStrategy
14
- def initialize(api_keys: [], header_name: 'X-API-Key', param_name: 'api_key')
15
- @api_keys = Array(api_keys)
81
+ # Full SHA-256 hex digest of a key. Store this instead of the raw key
82
+ # and look up presented keys by their digest.
83
+ #
84
+ # @param key [String]
85
+ # @return [String] 64-char hex digest
86
+ def self.digest(key)
87
+ Digest::SHA256.hexdigest(key)
88
+ end
89
+
90
+ # @param api_keys [String, Array<String>, nil] static list of valid API keys
91
+ # @param resolver [#call, nil] callable receiving the presented key and
92
+ # returning the account (truthy) or nil/false when unknown
93
+ # @param header_name [String] request header carrying the key
94
+ # @param param_name [String, nil] query/form parameter carrying the key.
95
+ # Defaults to nil (header only): keys placed in a URL are captured by
96
+ # access logs, proxies, and browser history. Pass 'api_key' to opt in.
97
+ # @yieldparam presented_key [String] the non-empty presented key
98
+ # @yieldreturn [Object, nil, false] the account behind the key, or
99
+ # nil/false when the key is unknown
100
+ # @raise [ArgumentError] if no source, or more than one, is given
101
+ # @raise [ArgumentError] if resolver: does not respond to #call
102
+ # @raise [ArgumentError] if api_keys: contains no non-empty API key
103
+ def initialize(api_keys: nil, resolver: nil, header_name: 'X-API-Key', param_name: nil, &block)
104
+ super()
105
+ @resolver = build_resolver(api_keys, resolver, block)
16
106
  @header_name = header_name
17
107
  @param_name = param_name
18
108
  end
19
109
 
20
110
  def authenticate(env, _requirement)
21
- # Try header first, then query parameter
111
+ # Header first; the parameter path is consulted only when opted in.
22
112
  api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]
23
113
 
24
- if api_key.nil?
114
+ if api_key.nil? && @param_name
25
115
  request = Otto::Request.new(env)
26
116
  api_key = request.params[@param_name]
27
117
  end
28
118
 
29
- return failure('No API key provided') unless api_key
119
+ # '' is truthy in Ruby; treat it, and a whitespace-only value, as a
120
+ # missing credential. The static list already refuses blank keys, so
121
+ # the resolver must never be asked about one either.
122
+ return failure('No API key provided') if api_key.nil? || (api_key.is_a?(String) && api_key.strip.empty?)
123
+
124
+ # A non-String credential (e.g. `?api_key[]=k` yields an Array) was
125
+ # still presented, so reject it terminally rather than handing it to
126
+ # the resolver. Credentials were explicitly presented and rejected:
127
+ # fail closed.
128
+ return failure('Invalid API key', terminal: true) unless api_key.is_a?(String)
30
129
 
31
- if @api_keys.empty? || valid_api_key?(api_key)
32
- # Create a simple user hash for API key authentication
33
- user_data = { api_key: api_key }
34
- success(user: user_data, api_key: api_key)
35
- else
36
- failure('Invalid API key')
130
+ # Resolver exceptions propagate deliberately (see class docs).
131
+ user = @resolver.call(api_key)
132
+ return failure('Invalid API key', terminal: true) unless user
133
+
134
+ # The most likely naive misuse: `->(k) { k if keys.include?(k) }`.
135
+ # Fail loud before the key can reach the result and env.
136
+ if user.is_a?(String) && constant_time_equal?(user, api_key)
137
+ raise ArgumentError,
138
+ 'APIKeyStrategy resolver returned the presented key as the user; ' \
139
+ 'return the account behind the key, not the key'
37
140
  end
141
+
142
+ # Identify the credential by a non-reversible fingerprint. The strategy
143
+ # itself never places the raw key in the result; `user` is the
144
+ # resolver's return value, verbatim (see class docs).
145
+ success(user: user,
146
+ auth_method: 'api_key',
147
+ api_key_fingerprint: key_fingerprint(api_key))
38
148
  end
39
149
 
40
150
  private
41
151
 
42
- # Constant-time membership check over the configured API keys. Compares
43
- # against every key without short-circuiting so match position/membership is
44
- # not leaked via timing.
45
- def valid_api_key?(api_key)
46
- @api_keys.reduce(false) do |matched, key|
47
- Rack::Utils.secure_compare(key, api_key) || matched
152
+ def build_resolver(api_keys, resolver, block)
153
+ sources = [api_keys, resolver, block].count { |source| !source.nil? }
154
+ if sources > 1
155
+ raise ArgumentError,
156
+ 'APIKeyStrategy: pass api_keys:, resolver:, or a block, not more than one'
157
+ end
158
+ # `api_keys: nil` and omitting every source are indistinguishable
159
+ # here, so one message covers both.
160
+ if sources.zero?
161
+ raise ArgumentError,
162
+ 'APIKeyStrategy requires a key source: at least one non-empty API key ' \
163
+ '(api_keys:), a resolver:, or a block'
164
+ end
165
+
166
+ return static_resolver(api_keys) unless api_keys.nil?
167
+ return block if block
168
+
169
+ raise ArgumentError, 'APIKeyStrategy resolver: must respond to #call' unless resolver.respond_to?(:call)
170
+
171
+ resolver
172
+ end
173
+
174
+ # Wrap a static key list in a resolver performing a constant-time,
175
+ # non-short-circuiting membership check.
176
+ def static_resolver(api_keys)
177
+ # `to_s` returns the caller's String and the strategy only ever
178
+ # receives a shallow freeze from config finalization, so the digest
179
+ # is taken now: a caller mutating its key after boot cannot change
180
+ # which credential is accepted, and the raw keys are not retained.
181
+ # Keys are matched verbatim, but a whitespace-only value is a blank
182
+ # configuration (`API_KEYS=" "`), not a credential: reject it so the
183
+ # fail-closed startup guarantee covers it.
184
+ digests = Array(api_keys).map(&:to_s).reject { |key| key.strip.empty? }
185
+ .map { |key| self.class.digest(key).freeze }.freeze
186
+ if digests.empty?
187
+ raise ArgumentError,
188
+ 'APIKeyStrategy requires at least one non-empty API key ' \
189
+ '(api_keys: was empty or contained only blank values)'
190
+ end
191
+
192
+ lambda do |presented|
193
+ presented_digest = self.class.digest(presented)
194
+ matched = digests.reduce(false) do |acc, digest|
195
+ Rack::Utils.secure_compare(digest, presented_digest) || acc
196
+ end
197
+ matched ? { api_key_fingerprint: presented_digest[0, 12] } : nil
48
198
  end
49
199
  end
200
+
201
+ # Constant-time equality that does not leak the length of either side:
202
+ # `Rack::Utils.secure_compare` returns immediately on a length mismatch,
203
+ # so compare fixed-width digests instead of the raw values.
204
+ def constant_time_equal?(left, right)
205
+ Rack::Utils.secure_compare(self.class.digest(left), self.class.digest(right))
206
+ end
207
+
208
+ # Short, non-reversible identifier for a key: enough to correlate
209
+ # requests and audit logs without exposing the credential.
210
+ def key_fingerprint(api_key)
211
+ self.class.digest(api_key)[0, 12]
212
+ end
50
213
  end
51
214
  end
52
215
  end
@@ -11,6 +11,7 @@ class Otto
11
11
  # Permission-based authentication strategy
12
12
  class PermissionStrategy < AuthStrategy
13
13
  def initialize(required_permissions, session_key: 'user_permissions')
14
+ super()
14
15
  @required_permissions = Array(required_permissions)
15
16
  @session_key = session_key
16
17
  end
@@ -11,6 +11,7 @@ class Otto
11
11
  # Role-based authentication strategy
12
12
  class RoleStrategy < AuthStrategy
13
13
  def initialize(allowed_roles, session_key: 'user_roles')
14
+ super()
14
15
  @allowed_roles = Array(allowed_roles)
15
16
  @session_key = session_key
16
17
  end
@@ -11,6 +11,7 @@ class Otto
11
11
  # Session-based authentication strategy
12
12
  class SessionStrategy < AuthStrategy
13
13
  def initialize(session_key: 'user_id', session_store: nil)
14
+ super()
14
15
  @session_key = session_key
15
16
  @session_store = session_store
16
17
  end