otto 2.1.0 → 2.2.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/workflows/release-gem.yml +5 -1
- data/{CLAUDE.md → AGENTS.md} +2 -2
- data/CHANGELOG.rst +12 -0
- data/Gemfile.lock +1 -1
- data/lib/otto/security/authentication/auth_strategy.rb +29 -2
- data/lib/otto/security/authentication/authorization_failure.rb +56 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +18 -3
- data/lib/otto/security/authentication.rb +2 -0
- data/lib/otto/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0616e034fc5859c42ed2eb48e1d123abdd1b36bca93422cf858d51331843c2c9
|
|
4
|
+
data.tar.gz: 667c3067cdd71ed41ef733c479798836522c32d37044edeb5f78d33c04b14818
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c219d4864ffc3090983ee50828279914dc052d0d65f5fd75dbaff812a1fee5d32880c325956d778c9a68e39e396e0f855f271eb3355ddee9d846e186586c57a2
|
|
7
|
+
data.tar.gz: d32b8d2235fe0d2c95510c4ec5f1623f30a6039bac1a309878af03907458fd9a51ea18f6e5584dfe88754a45cef25469f52ba72bee7847e97b87f2e481672e9d
|
|
@@ -132,7 +132,11 @@ jobs:
|
|
|
132
132
|
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
|
133
133
|
with:
|
|
134
134
|
bundler-cache: true
|
|
135
|
-
|
|
135
|
+
# Pinned to 3.3 (oldest non-experimental Ruby in the CI matrix).
|
|
136
|
+
# Latest stable (4.0) ships ipaddr 1.2.8 as a default gem, which
|
|
137
|
+
# bundler 2.7.x cannot override with the lockfile's 1.2.9 - the
|
|
138
|
+
# same reason Ruby 4.0 is `experimental: true` in ci.yml.
|
|
139
|
+
ruby-version: "3.3"
|
|
136
140
|
|
|
137
141
|
- name: Verify release tag matches Otto::VERSION
|
|
138
142
|
env:
|
data/{CLAUDE.md → AGENTS.md}
RENAMED
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,18 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
|
|
|
7
7
|
|
|
8
8
|
<!--scriv-insert-here-->
|
|
9
9
|
|
|
10
|
+
.. _changelog-2.2.0:
|
|
11
|
+
|
|
12
|
+
2.2.0 — 2026-06-09
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Added ``AuthorizationFailure`` result type for auth strategies to signal 403 Forbidden distinct from 401 Unauthorized. Strategies that perform combined authentication and authorization in one pass can now return ``authorization_failure(reason)`` when a valid credential is denied a permission, allowing ``RouteAuthWrapper`` to map the result to a proper 403 response rather than collapsing it to 401.
|
|
19
|
+
- Added ``#authorization_failure`` helper to ``AuthStrategy`` base class for consistent error signaling across strategy implementations.
|
|
20
|
+
- Extracted ``#strategy_auth_method`` private helper to handle anonymous strategy classes (common in tests) that have a nil ``#name``.
|
|
21
|
+
|
|
10
22
|
.. _changelog-2.1.0:
|
|
11
23
|
|
|
12
24
|
2.1.0 — 2026-05-27
|
data/Gemfile.lock
CHANGED
|
@@ -31,20 +31,47 @@ class Otto
|
|
|
31
31
|
Otto::Security::Authentication::StrategyResult.new(
|
|
32
32
|
session: session,
|
|
33
33
|
user: user,
|
|
34
|
-
auth_method: auth_method ||
|
|
34
|
+
auth_method: auth_method || strategy_auth_method,
|
|
35
35
|
metadata: metadata,
|
|
36
36
|
strategy_name: nil # Will be set by RouteAuthWrapper
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# Helper for authentication failure - return AuthFailure
|
|
41
|
+
#
|
|
42
|
+
# Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
|
|
43
|
+
# this to 401 Unauthorized. For a VALID credential that is not permitted,
|
|
44
|
+
# use #authorization_failure instead (403 Forbidden).
|
|
41
45
|
def failure(reason = nil)
|
|
42
46
|
Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
|
|
43
47
|
Otto::Security::Authentication::AuthFailure.new(
|
|
44
48
|
failure_reason: reason || 'Authentication failed',
|
|
45
|
-
auth_method:
|
|
49
|
+
auth_method: strategy_auth_method
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Helper for authorization failure - return AuthorizationFailure
|
|
54
|
+
#
|
|
55
|
+
# Use when the credential is valid but the authenticated subject is not
|
|
56
|
+
# permitted (wrong role, missing permission). RouteAuthWrapper maps this to
|
|
57
|
+
# 403 Forbidden, letting clients distinguish "authenticate again" (401) from
|
|
58
|
+
# "you lack this permission" (403). See AuthorizationFailure.
|
|
59
|
+
def authorization_failure(reason = nil)
|
|
60
|
+
Otto.logger.debug "[#{self.class}] Authorization denied: #{reason}" if reason
|
|
61
|
+
Otto::Security::Authentication::AuthorizationFailure.new(
|
|
62
|
+
failure_reason: reason || 'Authorization denied',
|
|
63
|
+
auth_method: strategy_auth_method
|
|
46
64
|
)
|
|
47
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Short auth_method label from the strategy class name. Anonymous strategy
|
|
70
|
+
# classes (Class.new(...), common in tests) have a nil #name, so fall back
|
|
71
|
+
# to a generic label rather than raising on nil#split.
|
|
72
|
+
def strategy_auth_method
|
|
73
|
+
(self.class.name || 'AuthStrategy').split('::').last
|
|
74
|
+
end
|
|
48
75
|
end
|
|
49
76
|
end
|
|
50
77
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# lib/otto/security/authentication/authorization_failure.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module Authentication
|
|
8
|
+
# Result for AUTHORIZATION failures (authenticated, but not permitted).
|
|
9
|
+
#
|
|
10
|
+
# This is distinct from AuthFailure, which represents an AUTHENTICATION
|
|
11
|
+
# failure (no/invalid/expired credential). A strategy that performs both
|
|
12
|
+
# authentication and authorization in one pass (e.g. a token strategy that
|
|
13
|
+
# also enforces a role/permission encoded in the route requirement) returns:
|
|
14
|
+
#
|
|
15
|
+
# * AuthFailure -> credential missing/invalid -> 401 Unauthorized
|
|
16
|
+
# * AuthorizationFailure -> credential valid, but denied -> 403 Forbidden
|
|
17
|
+
#
|
|
18
|
+
# Without this type a combined strategy could only return AuthFailure, and
|
|
19
|
+
# RouteAuthWrapper would collapse an authorization denial to 401 — leaving a
|
|
20
|
+
# client unable to distinguish "authenticate again" from "you lack this
|
|
21
|
+
# permission." The wrapper maps this type to ResponseBuilder#forbidden (403);
|
|
22
|
+
# see RouteAuthWrapper#handle_all_strategies_failed.
|
|
23
|
+
#
|
|
24
|
+
# NOTE: Otto's built-in Layer-1 role check (RoleAuthorization, driven by the
|
|
25
|
+
# `role=` route token) already yields 403 for role mismatches on a successful
|
|
26
|
+
# StrategyResult. This type covers the complementary case: a strategy that
|
|
27
|
+
# owns authorization itself (including permission tiers, which Layer-1 does
|
|
28
|
+
# not model) and needs to signal a 403 directly.
|
|
29
|
+
AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
|
|
30
|
+
# Authorization failures are not an authenticated request state. The
|
|
31
|
+
# request never reaches the handler, so handler-facing predicates report
|
|
32
|
+
# the same "no user context" shape AuthFailure does.
|
|
33
|
+
#
|
|
34
|
+
# @return [Boolean] False
|
|
35
|
+
def authenticated?
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] True (no user context attached to a denial)
|
|
40
|
+
def anonymous?
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Hash] Empty hash
|
|
45
|
+
def user_context
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] Debug representation
|
|
50
|
+
def inspect
|
|
51
|
+
"#<AuthorizationFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -106,11 +106,18 @@ class Otto
|
|
|
106
106
|
duration, total_start_time, failed_strategies)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
# Handle
|
|
110
|
-
next
|
|
109
|
+
# Handle a failure (authentication OR authorization) - record it and
|
|
110
|
+
# continue to the next strategy (OR logic; a later success still wins).
|
|
111
|
+
# AuthorizationFailure (valid credential, denied) is tagged so the
|
|
112
|
+
# final response is 403 instead of 401. See handle_all_strategies_failed.
|
|
113
|
+
next unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
|
|
111
114
|
|
|
112
115
|
log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
|
|
113
|
-
failed_strategies << {
|
|
116
|
+
failed_strategies << {
|
|
117
|
+
strategy: strategy_name,
|
|
118
|
+
reason: result.failure_reason,
|
|
119
|
+
authorization: result.is_a?(AuthorizationFailure),
|
|
120
|
+
}
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
# All strategies failed
|
|
@@ -156,6 +163,14 @@ class Otto
|
|
|
156
163
|
strategy_name: failure_strategy_name
|
|
157
164
|
)
|
|
158
165
|
|
|
166
|
+
# Authorization denial wins over authentication failure: if any strategy
|
|
167
|
+
# authenticated the subject but denied authorization (wrong role/missing
|
|
168
|
+
# permission), respond 403 Forbidden rather than 401 — the subject IS
|
|
169
|
+
# authenticated, they simply lack access. A bare 401 would (incorrectly)
|
|
170
|
+
# tell a logged-in client to re-authenticate.
|
|
171
|
+
authz_denial = failed_strategies.find { |f| f[:authorization] }
|
|
172
|
+
return @response_builder.forbidden(env, authz_denial[:reason]) if authz_denial
|
|
173
|
+
|
|
159
174
|
last_failure = if failed_strategies.any?
|
|
160
175
|
AuthFailure.new(
|
|
161
176
|
failure_reason: failed_strategies.last[:reason],
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
require_relative 'authentication/auth_strategy'
|
|
9
9
|
require_relative 'authentication/strategy_result'
|
|
10
10
|
require_relative 'authentication/auth_failure'
|
|
11
|
+
require_relative 'authentication/authorization_failure'
|
|
11
12
|
require_relative 'authentication/route_auth_wrapper'
|
|
12
13
|
|
|
13
14
|
# Load all strategies
|
|
@@ -31,4 +32,5 @@ class Otto
|
|
|
31
32
|
# Top-level backward compatibility aliases
|
|
32
33
|
StrategyResult = Security::Authentication::StrategyResult
|
|
33
34
|
AuthFailure = Security::Authentication::AuthFailure
|
|
35
|
+
AuthorizationFailure = Security::Authentication::AuthorizationFailure
|
|
34
36
|
end
|
data/lib/otto/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
@@ -149,8 +149,8 @@ files:
|
|
|
149
149
|
- ".reek.yml"
|
|
150
150
|
- ".rspec"
|
|
151
151
|
- ".rubocop.yml"
|
|
152
|
+
- AGENTS.md
|
|
152
153
|
- CHANGELOG.rst
|
|
153
|
-
- CLAUDE.md
|
|
154
154
|
- Gemfile
|
|
155
155
|
- Gemfile.lock
|
|
156
156
|
- LICENSE.txt
|
|
@@ -281,6 +281,7 @@ files:
|
|
|
281
281
|
- lib/otto/security/authentication.rb
|
|
282
282
|
- lib/otto/security/authentication/auth_failure.rb
|
|
283
283
|
- lib/otto/security/authentication/auth_strategy.rb
|
|
284
|
+
- lib/otto/security/authentication/authorization_failure.rb
|
|
284
285
|
- lib/otto/security/authentication/route_auth_wrapper.rb
|
|
285
286
|
- lib/otto/security/authentication/route_auth_wrapper/response_builder.rb
|
|
286
287
|
- lib/otto/security/authentication/route_auth_wrapper/role_authorization.rb
|