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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4e62574b6cb588f8dd99890758af5689dc23732d7a920c0023627a128a5ab5a
4
- data.tar.gz: 658e3f7cf3feedf1ee8a120bcb4a5121d2b70c0d5f584b96115ded86d6d2132c
3
+ metadata.gz: 0616e034fc5859c42ed2eb48e1d123abdd1b36bca93422cf858d51331843c2c9
4
+ data.tar.gz: 667c3067cdd71ed41ef733c479798836522c32d37044edeb5f78d33c04b14818
5
5
  SHA512:
6
- metadata.gz: f4426d3362c6f2ceb81ca1d8c1dfd55dad7b404218b6a3715394bec29e466b6bf0f09d290bb11fa157eaaa6c2889e702ccac97ba4f5496e947005cc083046332
7
- data.tar.gz: dca5f99161a47a01514f58a7959062d215b5371f2e8165f57399f77cece4529f57ae4e0cb83224fe5fd93acdd4575dc1b04bbb9682afa82f2ad4e98258b9786d
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
- ruby-version: ruby # latest stable Ruby installed by setup-ruby
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:
@@ -1,6 +1,6 @@
1
- # CLAUDE.md
1
+ # AGENTS.md
2
2
 
3
- This file provides essential guidance to Claude Code when working with Otto.
3
+ This file provides essential guidance to AI agents when working with Otto.
4
4
 
5
5
  ## Error Handler Registration
6
6
 
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (2.1.0)
4
+ otto (2.2.0)
5
5
  concurrent-ruby (~> 1.3, < 2.0)
6
6
  ipaddr (~> 1, < 2.0)
7
7
  logger (~> 1, < 2.0)
@@ -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 || self.class.name.split('::').last,
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: self.class.name.split('::').last
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 authentication failure - continue to next strategy
110
- next unless result.is_a?(AuthFailure)
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 << { strategy: strategy_name, reason: result.failure_reason }
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
@@ -3,5 +3,5 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.1.0'
6
+ VERSION = '2.2.0'
7
7
  end
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.1.0
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