otto 2.9.0 → 2.10.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 +7 -5
- data/AGENTS.md +22 -1
- data/CHANGELOG.rst +203 -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 +146 -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 +181 -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 +36 -22
- data/lib/otto/core/file_safety.rb +89 -31
- data/lib/otto/core/middleware_stack.rb +36 -36
- data/lib/otto/core/router.rb +62 -19
- 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/utils.rb +50 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +9 -11
- data/otto.gemspec +0 -2
- metadata +31 -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
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Authentication and authorization
|
|
2
|
+
|
|
3
|
+
Otto authenticates at the route handler boundary. Authentication is not a
|
|
4
|
+
middleware that runs before routing; only routes with `auth=` requirements are
|
|
5
|
+
wrapped. This lets a route declare its access requirement next to its HTTP
|
|
6
|
+
contract.
|
|
7
|
+
|
|
8
|
+
Authentication answers **who is this request from?** Authorization answers
|
|
9
|
+
**may that subject perform this action?** Keep those questions separate.
|
|
10
|
+
|
|
11
|
+
## Register strategies before the first request
|
|
12
|
+
|
|
13
|
+
Register named strategy instances during boot:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
otto = Otto.new('routes')
|
|
17
|
+
otto.add_auth_strategy(
|
|
18
|
+
'session',
|
|
19
|
+
Otto::Security::Authentication::Strategies::SessionStrategy.new(
|
|
20
|
+
session_key: 'user_id'
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
api_keys = ENV.fetch('API_KEYS').split(',').reject(&:empty?)
|
|
24
|
+
raise 'API_KEYS is empty' if api_keys.empty?
|
|
25
|
+
|
|
26
|
+
otto.add_auth_strategy(
|
|
27
|
+
'api_key',
|
|
28
|
+
Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: api_keys)
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`APIKeyStrategy` fails closed. Its constructor requires exactly one key source
|
|
33
|
+
(`api_keys:`, `resolver:`, or a block; see below) and raises `ArgumentError`
|
|
34
|
+
when none is given, or when an `api_keys:` list normalizes to empty (`[]` or
|
|
35
|
+
only blank strings), so the strategy enforces the check the example above makes
|
|
36
|
+
explicit. A request that presents a key is accepted only when that key matches a
|
|
37
|
+
configured key under constant-time comparison. Both sides are reduced to
|
|
38
|
+
fixed-width SHA-256 digests before comparing, so the check never short-circuits
|
|
39
|
+
on a length mismatch and configured key lengths are not observable. A blank
|
|
40
|
+
credential (empty or whitespace-only) is rejected as missing.
|
|
41
|
+
A presented-but-invalid key is a terminal failure, so it aborts the strategy
|
|
42
|
+
chain instead of falling through to a later strategy in a multi-strategy `OR`
|
|
43
|
+
route.
|
|
44
|
+
|
|
45
|
+
By default, the strategy reads only the `X-API-Key` header. Use `header_name:`
|
|
46
|
+
to select a different header. The query/form parameter path is opt-in via
|
|
47
|
+
`param_name:`, because a key placed in a URL is captured by access logs,
|
|
48
|
+
proxies, and browser history:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
Otto::Security::Authentication::Strategies::APIKeyStrategy.new(
|
|
52
|
+
api_keys: api_keys,
|
|
53
|
+
param_name: 'api_key' # caution: keys in URLs are logged; prefer the header
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The strategy never places the raw key in the result. `metadata[:api_key_fingerprint]`
|
|
58
|
+
(and, for the static list, `user[:api_key_fingerprint]`) holds a truncated
|
|
59
|
+
SHA-256 digest of the presented key, so audit logs can correlate requests
|
|
60
|
+
without recording the credential. With a resolver, `user` is whatever the
|
|
61
|
+
resolver returns, so that guarantee covers only the strategy's own fields; see
|
|
62
|
+
the rules below.
|
|
63
|
+
|
|
64
|
+
### Resolve keys from a database
|
|
65
|
+
|
|
66
|
+
A static list is the simple default. When keys live in a database, a
|
|
67
|
+
repository, or a cache, give the strategy a resolver instead. The resolver
|
|
68
|
+
receives the presented key and returns the account behind it, or `nil` when
|
|
69
|
+
there is none:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
APIKeyStrategy = Otto::Security::Authentication::Strategies::APIKeyStrategy
|
|
73
|
+
|
|
74
|
+
otto.add_auth_strategy(
|
|
75
|
+
'api_key',
|
|
76
|
+
APIKeyStrategy.new do |presented_key|
|
|
77
|
+
ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.account
|
|
78
|
+
end
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Anything that responds to `#call` works as well, passed as `resolver:`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
APIKeyStrategy.new(resolver: repo.method(:find_by_key))
|
|
86
|
+
APIKeyStrategy.new(resolver: ->(key) { KeyCache.fetch(APIKeyStrategy.digest(key)) })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The rules are the same in every form:
|
|
90
|
+
|
|
91
|
+
- Exactly one source: `api_keys:`, `resolver:`, or a block. Passing none, or
|
|
92
|
+
more than one, raises `ArgumentError`, as does a `resolver:` that does not
|
|
93
|
+
respond to `#call`.
|
|
94
|
+
- The resolver receives only the presented key, as a non-blank `String`, and
|
|
95
|
+
nothing else. The blank check (empty or whitespace-only) and the non-String
|
|
96
|
+
rejection run before it, so a missing or blank header is still the
|
|
97
|
+
non-terminal `No API key provided` failure and the resolver is never asked
|
|
98
|
+
about it.
|
|
99
|
+
- A `nil` or `false` return is a terminal `Invalid API key` failure, identical
|
|
100
|
+
to a static mismatch. It aborts the strategy chain with a 401. Every other
|
|
101
|
+
return value is a match, including empty containers: a `where(...)` relation,
|
|
102
|
+
an empty Array from `select`, or `{}` from a cache miss is truthy and
|
|
103
|
+
authenticates every presented key. Return one record (`find_by`, `first`) or
|
|
104
|
+
`nil`.
|
|
105
|
+
- Exceptions from the resolver propagate. The strategy does not rescue them,
|
|
106
|
+
so a database outage surfaces as an error rather than a silent 401, and can
|
|
107
|
+
never become a success.
|
|
108
|
+
- Whatever the resolver returns becomes `user` in the result, verbatim, and
|
|
109
|
+
`api_key_fingerprint` is still set in the result metadata. The strategy
|
|
110
|
+
itself never places the raw key in the result, but the result is stored in
|
|
111
|
+
`env['otto.strategy_result']` and exposed to handlers, so anything the
|
|
112
|
+
application serializes or logs from it carries `user`. It is the resolver's
|
|
113
|
+
responsibility not to return an object that carries the raw key. Return the
|
|
114
|
+
account, not the `ApiKey` row that stores the key, and store digests. A
|
|
115
|
+
resolver that returns the presented key string itself as the user raises
|
|
116
|
+
`ArgumentError`.
|
|
117
|
+
|
|
118
|
+
The strategy cannot make a black-box lookup constant-time. Store SHA-256
|
|
119
|
+
digests rather than raw keys and look up by `APIKeyStrategy.digest(key)`, as
|
|
120
|
+
in the example above. This produces a fixed-width lookup value and keeps raw
|
|
121
|
+
keys out of storage, but it does not make the application's database or cache
|
|
122
|
+
lookup constant-time. A database dump of high-entropy keys (generate them with
|
|
123
|
+
`SecureRandom`, 32 bytes or more) does not expose usable credentials through a
|
|
124
|
+
practical brute-force search. Unsalted SHA-256 is not a password hash, so do
|
|
125
|
+
not accept user-chosen keys. `APIKeyStrategy.digest(key)` returns the full hex
|
|
126
|
+
digest; the fingerprint in the result is its first 12 characters.
|
|
127
|
+
|
|
128
|
+
### What `APIKeyStrategy` does not provide
|
|
129
|
+
|
|
130
|
+
`APIKeyStrategy` is a conventional small static-allowlist authenticator
|
|
131
|
+
included as a low-dependency convenience and reference implementation. The
|
|
132
|
+
fail-closed changes in #256 make that existing convenience safe by default;
|
|
133
|
+
they do not establish that all Otto API-key authentication should use
|
|
134
|
+
boot-loaded lists.
|
|
135
|
+
|
|
136
|
+
The shape is deliberate and has precedent. A key list loaded once at start,
|
|
137
|
+
checked with a constant-time comparison, is what the Rails guides show for
|
|
138
|
+
`authenticate_or_request_with_http_token` (a token from the environment,
|
|
139
|
+
compared with `ActiveSupport::SecurityUtils.secure_compare`), what the
|
|
140
|
+
Kubernetes API server does with its `--token-auth-file` CSV, and what Traefik
|
|
141
|
+
and nginx do with their basic-auth user lists. Warden and Devise token
|
|
142
|
+
examples, and the common Sinatra `before` filter that compares a header
|
|
143
|
+
against `ENV['API_KEY']`, are the same pattern. All of them share the
|
|
144
|
+
properties here: no source configured means no access, the set of keys is
|
|
145
|
+
fixed for the life of the process, and a mismatch is final. The resolver form
|
|
146
|
+
is the escape hatch for the dynamic case those tools also leave to an external
|
|
147
|
+
store.
|
|
148
|
+
|
|
149
|
+
In either form, the strategy has no native support for:
|
|
150
|
+
|
|
151
|
+
- Runtime addition or immediate revocation.
|
|
152
|
+
- Expiration.
|
|
153
|
+
- Per-client roles or scopes.
|
|
154
|
+
- Ownership and descriptive metadata.
|
|
155
|
+
- Usage quotas.
|
|
156
|
+
- A management API.
|
|
157
|
+
- Persisted audit history.
|
|
158
|
+
- Hashed verifier storage.
|
|
159
|
+
|
|
160
|
+
With a static list, none of these exist: changing the set of valid keys means
|
|
161
|
+
restarting the process, and every key is equal. With a resolver, the first
|
|
162
|
+
four and the last become the application's key store's responsibility. The
|
|
163
|
+
resolver decides whether a key is still valid, and whatever it returns is the
|
|
164
|
+
`user` the handler sees, so roles, scopes, ownership, and expiry live on that
|
|
165
|
+
record. Quotas, a management API, and audit history remain outside the
|
|
166
|
+
strategy entirely. An application that needs them should build a custom
|
|
167
|
+
`AuthStrategy` around its own key model, using `APIKeyStrategy` as the
|
|
168
|
+
reference for header handling, terminal failures, and fingerprinting.
|
|
169
|
+
|
|
170
|
+
A strategy implements `authenticate(env, requirement)` and returns a
|
|
171
|
+
`StrategyResult`, `AuthFailure`, or `AuthorizationFailure`. Subclass
|
|
172
|
+
`Otto::Security::Authentication::AuthStrategy` to use its `success`, `failure`,
|
|
173
|
+
and `authorization_failure` helpers.
|
|
174
|
+
|
|
175
|
+
Strategy names must be unique. Registration and other security configuration
|
|
176
|
+
must happen before the first request, when Otto freezes configuration.
|
|
177
|
+
|
|
178
|
+
## Protect a route
|
|
179
|
+
|
|
180
|
+
Put the strategy name in the route file:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
GET /profile Profile#show auth=session
|
|
184
|
+
GET /api/data Api::Data response=json auth=api_key
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The strategy receives the full requirement string. Exact names resolve directly;
|
|
188
|
+
colon-qualified requirements such as `oauth:google` first try an exact match,
|
|
189
|
+
then fall back to the registered `oauth` strategy while preserving the full
|
|
190
|
+
requirement for that strategy.
|
|
191
|
+
|
|
192
|
+
A route without `auth=` receives an anonymous `StrategyResult` and runs without
|
|
193
|
+
an authentication check. `StrategyResult#authenticated?` tells application code
|
|
194
|
+
whether a user is present; `auth_attempt_succeeded?` tells it whether the
|
|
195
|
+
current route's authentication attempt produced an authenticated result.
|
|
196
|
+
|
|
197
|
+
## Combine strategies with OR logic
|
|
198
|
+
|
|
199
|
+
Use comma-separated requirements when more than one credential mechanism may
|
|
200
|
+
satisfy the same route:
|
|
201
|
+
|
|
202
|
+
```text
|
|
203
|
+
GET /api/data Api::Data#show auth=session,api_key,oauth response=json
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The chain behaves as follows:
|
|
207
|
+
|
|
208
|
+
1. Strategies run left to right.
|
|
209
|
+
2. The first authenticated success wins and later strategies do not run.
|
|
210
|
+
3. A plain failure allows the next strategy to run.
|
|
211
|
+
4. An anonymous success, such as `noauth`, is held as a fallback while the rest
|
|
212
|
+
of the chain runs.
|
|
213
|
+
5. If every strategy fails, Otto returns an authentication or authorization
|
|
214
|
+
failure. If an anonymous fallback exists and no terminal failure occurred,
|
|
215
|
+
it wins.
|
|
216
|
+
6. A terminal authentication failure stops the chain immediately. Strategies
|
|
217
|
+
should mark a failure terminal only when explicit credentials were presented,
|
|
218
|
+
examined, and rejected. This prevents invalid credentials from degrading to
|
|
219
|
+
an anonymous success in a mixed chain such as
|
|
220
|
+
`auth=basicauth,noauth`.
|
|
221
|
+
|
|
222
|
+
Put the common and least expensive strategy first, but do not use ordering to
|
|
223
|
+
make invalid explicit credentials harmless: terminal failures are intentionally
|
|
224
|
+
fail-closed.
|
|
225
|
+
|
|
226
|
+
## Route-level roles
|
|
227
|
+
|
|
228
|
+
Add `role=` for a broad route-level authorization check:
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
GET /admin Admin::Dashboard auth=session role=admin
|
|
232
|
+
GET /edit Editorial#edit auth=session role=admin,editor
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`role=` checks the successful strategy result; it does not read
|
|
236
|
+
`env['rack.session']` independently. The built-in `SessionStrategy` returns a
|
|
237
|
+
user containing only `id` and `user_id`, so it is sufficient for authentication
|
|
238
|
+
but not for these role checks. If the application uses role-protected session
|
|
239
|
+
routes, register a role-aware application strategy instead of the built-in
|
|
240
|
+
strategy shown earlier:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
class RoleAwareSessionStrategy < Otto::Security::Authentication::AuthStrategy
|
|
244
|
+
def authenticate(env, _requirement)
|
|
245
|
+
session = env['rack.session']
|
|
246
|
+
return failure('No session available') unless session
|
|
247
|
+
|
|
248
|
+
user_id = session['user_id']
|
|
249
|
+
return failure('Not authenticated') unless user_id
|
|
250
|
+
|
|
251
|
+
success(
|
|
252
|
+
user: {
|
|
253
|
+
id: user_id,
|
|
254
|
+
roles: Array(session['user_roles']).map(&:to_s),
|
|
255
|
+
},
|
|
256
|
+
session: session,
|
|
257
|
+
auth_method: 'session'
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
otto.add_auth_strategy('session', RoleAwareSessionStrategy.new)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Multiple route roles use OR logic. The role check reads, in precedence order:
|
|
266
|
+
|
|
267
|
+
1. `result.user_roles`, if the result exposes it
|
|
268
|
+
2. `result.user[:roles]` or `result.user['roles']` for a Hash-backed user
|
|
269
|
+
3. `result.user.roles`, falling back to `result.user.role`, for an object-backed user
|
|
270
|
+
4. `result.metadata[:user_roles]`
|
|
271
|
+
|
|
272
|
+
Use strings for result-level, Hash-backed, or metadata roles so they match the
|
|
273
|
+
string values parsed from the route. Object-backed `#roles` and `#role` values
|
|
274
|
+
are normalized to strings.
|
|
275
|
+
|
|
276
|
+
Missing authentication returns `401`. A valid authenticated subject without one
|
|
277
|
+
of the required roles returns `403`. `response=json` makes these route errors
|
|
278
|
+
JSON regardless of the request's `Accept` header.
|
|
279
|
+
|
|
280
|
+
## Resource-level authorization
|
|
281
|
+
|
|
282
|
+
Route-level roles cannot decide ownership or relationship rules without loading
|
|
283
|
+
the resource. Put that decision in a Logic class's `raise_concerns` method:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
class Posts::Edit
|
|
287
|
+
def initialize(strategy_result, params, _locale)
|
|
288
|
+
@context = strategy_result
|
|
289
|
+
@params = params
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def raise_concerns
|
|
293
|
+
@post = Post.find(@params[:id])
|
|
294
|
+
return if @post.user_id == @context.user_id
|
|
295
|
+
|
|
296
|
+
raise Otto::Security::AuthorizationError.new(
|
|
297
|
+
'Cannot edit another user\'s post',
|
|
298
|
+
resource: 'Post',
|
|
299
|
+
action: 'edit',
|
|
300
|
+
user_id: @context.user_id
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def process
|
|
305
|
+
# Perform the edit.
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`Otto::Security::AuthorizationError` is registered automatically and produces a
|
|
311
|
+
`403` response. It can carry resource, action, and user ID context for structured
|
|
312
|
+
logging. Authenticate first, then perform resource-level checks; do not rely on
|
|
313
|
+
an ownership check as a substitute for authentication.
|
|
314
|
+
|
|
315
|
+
## Strategy result contract
|
|
316
|
+
|
|
317
|
+
Otto stores the result for the request in `env['otto.strategy_result']` and uses
|
|
318
|
+
it to construct Logic-class context. Useful accessors include:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
result.authenticated?
|
|
322
|
+
result.anonymous?
|
|
323
|
+
result.user_id
|
|
324
|
+
result.user_name
|
|
325
|
+
result.has_role?('admin')
|
|
326
|
+
result.has_permission?('write')
|
|
327
|
+
result.session
|
|
328
|
+
result.metadata
|
|
329
|
+
result.strategy_name
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Application code should read the result created by Otto rather than constructing
|
|
333
|
+
its own `StrategyResult`. The `Data` record does not allow member reassignment,
|
|
334
|
+
but contained `session`, `user`, and `metadata` objects are not deep-frozen;
|
|
335
|
+
their mutability remains the application's responsibility.
|
|
336
|
+
|
|
337
|
+
## Failure and response behavior
|
|
338
|
+
|
|
339
|
+
- Unknown strategy names fail before any strategy in the route runs.
|
|
340
|
+
- Authentication failures are `401`; browser-oriented failures may redirect to
|
|
341
|
+
the configured login path, while JSON routes return JSON.
|
|
342
|
+
- Authorization failures are `403` and should not ask an already authenticated
|
|
343
|
+
subject to authenticate again.
|
|
344
|
+
- A strategy should return an `AuthorizationFailure` when credentials are valid
|
|
345
|
+
but the subject is not permitted, and an `AuthFailure` when authentication did
|
|
346
|
+
not succeed.
|
|
347
|
+
- Failure reasons and attempted strategies are included in Otto's structured
|
|
348
|
+
authentication logging; do not put secrets or raw credentials in those
|
|
349
|
+
reasons.
|
|
350
|
+
|
|
351
|
+
## Built-in strategy starting points
|
|
352
|
+
|
|
353
|
+
Otto includes these strategy classes as implementation starting points:
|
|
354
|
+
|
|
355
|
+
- `SessionStrategy` — reads a configured key from `env['rack.session']`.
|
|
356
|
+
- `APIKeyStrategy` — checks the configured header; the query parameter is opt-in
|
|
357
|
+
via `param_name:`. Keys come from a non-empty `api_keys:` list, a `resolver:`
|
|
358
|
+
callable, or a block that looks the presented key up; a rejected key is a
|
|
359
|
+
terminal failure, and the result exposes only a key fingerprint.
|
|
360
|
+
- `RoleStrategy` — checks session roles against allowed roles or a
|
|
361
|
+
colon-qualified requirement.
|
|
362
|
+
- `PermissionStrategy` — checks application-provided permission data.
|
|
363
|
+
- `NoAuthStrategy` — produces an anonymous success for an explicitly configured
|
|
364
|
+
anonymous fallback.
|
|
365
|
+
|
|
366
|
+
For production credentials, choose the credential storage, rotation, transport,
|
|
367
|
+
and revocation policy in the application. Otto supplies the route boundary and
|
|
368
|
+
result contract; it does not provide a user database or a universal session
|
|
369
|
+
store.
|
|
370
|
+
|
|
371
|
+
## Related contracts
|
|
372
|
+
|
|
373
|
+
- [Route syntax](../reference/route-syntax.md) — `auth=`, `role=`, and route
|
|
374
|
+
parsing rules.
|
|
375
|
+
- [Routing guide](routing.md) — choosing controller, Logic, and lambda handlers.
|
|
376
|
+
- [Configuration freezing](configuration_freezing.md) — boot-time mutation
|
|
377
|
+
boundary and multi-step setup.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Caddy on-demand TLS
|
|
2
|
+
|
|
3
|
+
`Otto::CaddyTLS` provides the permission endpoint Caddy calls before obtaining
|
|
4
|
+
or loading a certificate for a domain. Otto owns the route, parameter-shape
|
|
5
|
+
checks, access guard, response semantics, and fail-closed behavior. The
|
|
6
|
+
application supplies one decision block: whether the requested domain is
|
|
7
|
+
allowed.
|
|
8
|
+
|
|
9
|
+
This guide covers the shipped integration. [ADR-003: Caddy TLS route-based
|
|
10
|
+
integration](../adr/adr-003-caddy-tls-route-based-integration.md) explains the
|
|
11
|
+
rejected alternatives and the security rationale in more detail.
|
|
12
|
+
|
|
13
|
+
## Enable the endpoint
|
|
14
|
+
|
|
15
|
+
Configure it before the first request:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require 'otto'
|
|
19
|
+
|
|
20
|
+
otto = Otto.new('routes')
|
|
21
|
+
otto.enable_caddy_tls! do |domain|
|
|
22
|
+
MyApp::CustomDomain.verified?(domain)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
run otto
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The block receives the stripped `domain` string from `?domain=`. Otto checks
|
|
29
|
+
that the parameter is a non-empty string, but does not validate DNS syntax,
|
|
30
|
+
lowercase the value, remove a trailing dot, or convert internationalized names.
|
|
31
|
+
Perform any application-specific hostname normalization and validation in the
|
|
32
|
+
block before querying domain data. A truthy result returns `200 OK`; a falsey
|
|
33
|
+
result returns `403 Forbidden`. An exception raised by the block is logged and
|
|
34
|
+
treated as a denial.
|
|
35
|
+
|
|
36
|
+
The default endpoint is `/_caddy/tls-permission`. No entry in the routes file is
|
|
37
|
+
required. Enabling without a block raises `ArgumentError`, and repeated enable
|
|
38
|
+
calls are idempotent: the first configuration and decision block remain active.
|
|
39
|
+
|
|
40
|
+
## Configure Caddy
|
|
41
|
+
|
|
42
|
+
`on_demand_tls` is a global Caddy option, and configuring its permission check
|
|
43
|
+
does not enable on-demand TLS for a site. A complete minimal configuration is:
|
|
44
|
+
|
|
45
|
+
```caddyfile
|
|
46
|
+
{
|
|
47
|
+
on_demand_tls {
|
|
48
|
+
permission http http://127.0.0.1:PORT/_caddy/tls-permission
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
https:// {
|
|
53
|
+
tls {
|
|
54
|
+
on_demand
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reverse_proxy 127.0.0.1:APP_PORT
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Replace `PORT` with the loopback port serving the Otto permission endpoint and
|
|
62
|
+
`APP_PORT` with the application port receiving normal traffic. If the same Otto
|
|
63
|
+
app serves both, the ports may be the same.
|
|
64
|
+
|
|
65
|
+
Caddy also documents `ask` as a backwards-compatible shortcut for the built-in
|
|
66
|
+
HTTP permission module:
|
|
67
|
+
|
|
68
|
+
```caddyfile
|
|
69
|
+
{
|
|
70
|
+
on_demand_tls {
|
|
71
|
+
ask http://127.0.0.1:PORT/_caddy/tls-permission
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Both forms append `?domain=<host>` and treat a non-`2xx` response as a denial.
|
|
77
|
+
Validate the chosen form with the Caddy version you deploy:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
caddy adapt --config Caddyfile --adapter caddyfile
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
See Caddy's [`on_demand_tls` documentation](https://caddyserver.com/docs/caddyfile/options#on-demand-tls)
|
|
84
|
+
for the current module syntax and deployment requirements.
|
|
85
|
+
|
|
86
|
+
## Request contract
|
|
87
|
+
|
|
88
|
+
The endpoint accepts a `GET` request with a string `domain` query parameter:
|
|
89
|
+
|
|
90
|
+
| Request | Result |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `GET /_caddy/tls-permission?domain=verified.example` and callback allows | `200`, `text/plain`, `OK` |
|
|
93
|
+
| Domain missing, blank, or array-valued | `400`, callback is not called |
|
|
94
|
+
| Callback returns false or `nil` | `403`, `text/plain`, `Forbidden` |
|
|
95
|
+
| Callback raises | `403`, callback error is logged |
|
|
96
|
+
| `HEAD` with a valid domain | Same status decision, empty response body |
|
|
97
|
+
|
|
98
|
+
Only `domain` reaches the callback. Additional query parameters are ignored;
|
|
99
|
+
there is no verification-bypass parameter.
|
|
100
|
+
|
|
101
|
+
## Security boundary
|
|
102
|
+
|
|
103
|
+
The localhost guard is enabled by default. For the protected endpoint, both
|
|
104
|
+
conditions must hold:
|
|
105
|
+
|
|
106
|
+
1. the raw socket peer is loopback; and
|
|
107
|
+
2. no non-empty forwarding header indicates that the request was relayed
|
|
108
|
+
through another proxy.
|
|
109
|
+
|
|
110
|
+
The guard authenticates the original peer, not the resolved client address in
|
|
111
|
+
`REMOTE_ADDR` or `otto.client_ip`. This matters when a loopback proxy is also a
|
|
112
|
+
trusted proxy: forwarded headers must not be able to turn a remote caller into a
|
|
113
|
+
local one. The guard rejects the endpoint request when any of these headers has a
|
|
114
|
+
non-empty value:
|
|
115
|
+
|
|
116
|
+
- `X-Forwarded-For`
|
|
117
|
+
- `X-Real-IP`
|
|
118
|
+
- `X-Client-IP`
|
|
119
|
+
- `Forwarded`
|
|
120
|
+
- `X-Forwarded-Host`
|
|
121
|
+
- `X-Forwarded-Proto`
|
|
122
|
+
- `X-Forwarded-Scheme`
|
|
123
|
+
- `X-Forwarded-SSL`
|
|
124
|
+
- `X-Forwarded-Port`
|
|
125
|
+
|
|
126
|
+
The list is the full set (`Otto::Utils::RELAY_MARKER_HEADERS`): every forwarding
|
|
127
|
+
carrier Otto knows, including the authority headers, not only the client-IP
|
|
128
|
+
carriers. A front server that adds any of them to the permission request, even
|
|
129
|
+
one that only records the scheme or port, turns the call into a relayed request
|
|
130
|
+
and the guard returns `401`. Configure the permission endpoint so the request
|
|
131
|
+
reaches Otto without forwarding headers.
|
|
132
|
+
|
|
133
|
+
The guard is path-scoped. Other application routes pass through it unchanged,
|
|
134
|
+
and path normalization is shared with the router so encoded or trailing-slash
|
|
135
|
+
variants cannot bypass the check.
|
|
136
|
+
|
|
137
|
+
This protection assumes the Rack server leaves the actual connecting peer in
|
|
138
|
+
`REMOTE_ADDR` and that no earlier proxy or middleware removes relay-marker
|
|
139
|
+
headers before Otto records them. If PROXY protocol or an outer middleware
|
|
140
|
+
rewrites either input, verify that Otto still receives an authentic peer address
|
|
141
|
+
and the original forwarding markers. The strongest boundary is a dedicated
|
|
142
|
+
loopback-only listener.
|
|
143
|
+
|
|
144
|
+
## Deployment topology
|
|
145
|
+
|
|
146
|
+
The recommended deployment is a small Otto app bound to a loopback-only port on
|
|
147
|
+
the same host as Caddy:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Caddy -- loopback --> Otto CaddyTLS endpoint -- authenticated app channel --> domain data
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Caddy expects this decision in a few milliseconds. Prefer an indexed local
|
|
154
|
+
lookup or in-process cache. If the permission app must call an internal service,
|
|
155
|
+
use a short timeout and cache the result: an exception or timeout that reaches
|
|
156
|
+
the block's fail-closed wrapper becomes a denial and can fail the TLS handshake.
|
|
157
|
+
That data channel is an application responsibility; it does not widen the Caddy
|
|
158
|
+
endpoint's network trust boundary.
|
|
159
|
+
|
|
160
|
+
If the endpoint is mounted in a larger public app, add defense in depth:
|
|
161
|
+
|
|
162
|
+
- bind a dedicated permission app to `127.0.0.1` where possible;
|
|
163
|
+
- block the endpoint path at the public proxy;
|
|
164
|
+
- ensure the proxy's direct Caddy control-plane request is not relayed with
|
|
165
|
+
forwarding headers;
|
|
166
|
+
- keep the guard enabled unless network isolation is independently enforced.
|
|
167
|
+
|
|
168
|
+
## Disabling the guard
|
|
169
|
+
|
|
170
|
+
`localhost_only: false` removes the built-in access control and logs a warning:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
otto.enable_caddy_tls!(localhost_only: false) do |domain|
|
|
174
|
+
MyApp::CustomDomain.verified?(domain)
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Use this only when the endpoint is isolated by a stronger network-level control
|
|
179
|
+
that you have verified. It is not the normal deployment path.
|
|
180
|
+
|
|
181
|
+
## Custom endpoint
|
|
182
|
+
|
|
183
|
+
An application can choose another endpoint path:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
otto.enable_caddy_tls!(endpoint: '/internal/acme/permission') do |domain|
|
|
187
|
+
MyApp::CustomDomain.verified?(domain)
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Otto normalizes the configured endpoint before registering the route and guard.
|
|
192
|
+
The endpoint must still be configured before the first request; attempting to
|
|
193
|
+
change it after configuration freezes raises `FrozenError`.
|
|
194
|
+
|
|
195
|
+
## Troubleshooting
|
|
196
|
+
|
|
197
|
+
- **Caddy receives a denial:** check that the request reaches the configured
|
|
198
|
+
loopback port, includes a non-empty `domain`, and that the callback returns
|
|
199
|
+
truthy for that exact stripped domain.
|
|
200
|
+
- **The callback is never called:** a missing/blank domain produces `400`, while
|
|
201
|
+
a non-loopback peer or any non-empty forwarding header produces `401`.
|
|
202
|
+
- **A proxied public request reaches the same app:** keep the forwarding-header
|
|
203
|
+
rejection and add a proxy path block or a dedicated loopback-only endpoint.
|
|
204
|
+
- **A second `enable_caddy_tls!` call does not change behavior:** this is
|
|
205
|
+
intentional idempotency; configure the endpoint and block on the first call.
|