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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +5 -0
- data/.github/workflows/ci.yml +11 -8
- data/.github/workflows/claude-code-review.yml +38 -13
- data/.github/workflows/claude.yml +10 -8
- data/.github/workflows/code-smells.yml +5 -5
- data/.github/workflows/release-gem.yml +2 -2
- data/.github/workflows/ruby-lint.yml +3 -3
- data/.github/workflows/yardoc.yml +5 -5
- data/.gitignore +1 -5
- data/.rubocop_todo.yml +8 -5
- data/AGENTS.md +40 -1
- data/CHANGELOG.rst +235 -0
- data/Gemfile +3 -3
- data/Gemfile.lock +11 -15
- data/README.md +75 -37
- data/docs/README.md +166 -0
- data/docs/adr/README.md +16 -0
- data/docs/adr/adr-001-route-authentication-at-handler-boundary.md +38 -0
- data/docs/adr/adr-002-multi-strategy-authentication-and-authorization.md +50 -0
- data/docs/adr/adr-003-caddy-tls-route-based-integration.md +48 -0
- data/docs/adr/adr-004-separate-compatibility-from-security-maintenance.md +58 -0
- data/docs/guides/authentication.md +377 -0
- data/docs/guides/caddy-tls.md +205 -0
- data/docs/guides/configuration_freezing.md +157 -0
- data/docs/guides/enrichment.md +161 -0
- data/docs/guides/forwarded-authority.md +249 -0
- data/docs/guides/geo-country.md +168 -0
- data/docs/guides/ip_privacy.md +39 -0
- data/docs/guides/ipaddr-encoding-quirk.md +56 -0
- data/docs/guides/mcp.md +282 -0
- data/docs/guides/privacy.md +193 -0
- data/docs/guides/routing.md +293 -0
- data/docs/guides/structured_logging.md +281 -0
- data/docs/guides/testing-guide.md +391 -0
- data/docs/maintainers/github-actions.md +41 -0
- data/docs/maintainers/investigations/.gitignore +2 -0
- data/docs/migrating/v2.0.0.md +337 -0
- data/docs/reference/authentication.md +290 -0
- data/docs/reference/route-syntax.md +181 -0
- data/docs/reference/runtime-and-dependency-security.md +86 -0
- data/examples/advanced_routes/README.md +43 -57
- data/examples/authentication_strategies/README.md +37 -196
- data/examples/basic/README.md +24 -39
- data/examples/basic/config.ru +0 -1
- data/examples/caddy_tls_demo/README.md +8 -2
- data/examples/lambda_handlers/README.md +11 -2
- data/examples/mcp_demo/README.md +75 -161
- data/examples/mcp_demo/config.ru +1 -0
- data/examples/security_features/README.md +36 -234
- data/lib/otto/caddy_tls/localhost_guard.rb +22 -24
- data/lib/otto/core/configuration.rb +40 -23
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +105 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +94 -18
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/env_keys.rb +20 -2
- data/lib/otto/mcp/auth/token.rb +10 -4
- data/lib/otto/mcp/core.rb +23 -5
- data/lib/otto/mcp/endpoint.rb +41 -0
- data/lib/otto/mcp/errors.rb +15 -0
- data/lib/otto/mcp/options.rb +292 -0
- data/lib/otto/mcp/protocol.rb +52 -22
- data/lib/otto/mcp/rate_limiting.rb +175 -97
- data/lib/otto/mcp/registry.rb +14 -14
- data/lib/otto/mcp/schema_validation.rb +20 -12
- data/lib/otto/mcp/server.rb +131 -32
- data/lib/otto/optional_dependency.rb +57 -0
- data/lib/otto/privacy/config.rb +10 -8
- data/lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb +22 -5
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +181 -18
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +1 -0
- data/lib/otto/security/authentication/strategy_result.rb +58 -28
- data/lib/otto/security/config.rb +328 -14
- data/lib/otto/security/configurator.rb +36 -6
- data/lib/otto/security/core.rb +19 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +105 -4
- data/lib/otto/security/middleware/rate_limit_middleware.rb +1 -6
- data/lib/otto/security/rate_limiter.rb +81 -46
- data/lib/otto/static.rb +38 -0
- data/lib/otto/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +130 -13
- data/otto.gemspec +0 -2
- metadata +32 -41
- data/docs/.gitignore +0 -10
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +0 -1105
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +0 -376
- data/docs/enrichment.md +0 -128
- data/docs/geo-country.md +0 -181
- data/docs/ipaddr-encoding-quirk.md +0 -34
- data/docs/migrating/v2.0.0-pre1.md +0 -276
- data/docs/migrating/v2.0.0-pre2.md +0 -338
- data/docs/modern-authentication-authorization-landscape.md +0 -558
- data/docs/multi-strategy-authentication-design.md +0 -1401
- data/docs/reverse-proxy-network-services.md +0 -371
data/lib/otto/mcp/server.rb
CHANGED
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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
|
-
#
|
|
65
|
-
if @
|
|
152
|
+
# Innermost (last to execute): schema validation, closest to the app.
|
|
153
|
+
if @enable_validation
|
|
66
154
|
middleware.add_with_position(
|
|
67
|
-
Otto::MCP::
|
|
68
|
-
|
|
69
|
-
position: :first
|
|
155
|
+
Otto::MCP::SchemaValidationMiddleware,
|
|
156
|
+
position: :innermost
|
|
70
157
|
)
|
|
71
|
-
Otto.logger.debug '[MCP]
|
|
158
|
+
Otto.logger.debug '[MCP] Schema validation enabled (executes last)' if Otto.debug
|
|
72
159
|
end
|
|
73
160
|
|
|
74
|
-
#
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
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'] =
|
|
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
|
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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] (
|
|
77
|
-
# 3. result.user
|
|
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
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|