liberty 0.4.0 → 0.5.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: 9450a8fadf0c6726d4249f2b6a62f4918045795f199be8ec8bec29412bbaebc1
4
- data.tar.gz: 55ea606054dabaeec94add7f6b5fe63a5d38add6eca13dc9f4fc1ef2754ad5c2
3
+ metadata.gz: fb4d162f0b2ce1791b670b9b9093a8638737aec1c142908d321ebcba16499c4f
4
+ data.tar.gz: 7da18983c0aa557d727059daef386d429a8a1e8abeb4cd3473b8ec2ee0e76104
5
5
  SHA512:
6
- metadata.gz: 17e9cd7824c5b5038daee7fd5d3d441886865dc78562797cd502b8f96f6fd9cd48b2ed92da934265321757a2237e3682092e8653b1b4370260d73b3d9caef253
7
- data.tar.gz: 7416e1f47f06cb05dd7fbaadc777f71bbac9bc828a5954f4db401c52e42abc88777ee3fd49ad1e24e22e412953e7d2b188b2a6983b153ea36f535f02a5f7d48c
6
+ metadata.gz: 9b657a059f19264ebab5a4cd059f50bf5d6c9057b700b4a6e4121ca35583ad1dd1f3cbe28988993a59f0e44713d8912a8d47699b0cbad66ae2e9ebcaae17e489
7
+ data.tar.gz: 3a6e8297c743040dc5d74f1cfbba70c13ba40a2d575e9da70859c924c0f403959b9ff13f4ec733a12e82f7b8ef1d94646d025c97fb6f749bb16202e237b56880
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.5.0] - 2026-09-17
2
+
3
+ ### Breaking changes
4
+
5
+ - Replaces the 0.4.0 authentication hooks, which could only answer a 401. Routes now name their authenticator: `responds_to` takes a required `authenticated_by:` keyword, and a route that names none fails at load. Gone are the endpoint hooks `authenticated?`, `authorized?`, and `www_authenticate_header`, and Liberty's built-in 401 and 403 responses. Applications on 0.4.0 move their credential check into an authenticator and their 401 into a challenge endpoint. Applications on 0.3.x name `Liberty::Authenticators::Public` on every route they mean to leave open.
6
+
7
+ ### Added
8
+
9
+ - Authenticators. A subclass of `Liberty::Authenticator` answers `principal` and `challenge_endpoint_class`. Liberty builds one per request, injects the request, and asks before the endpoint exists, then builds either the endpoint or the challenge. The challenge is an ordinary endpoint class the application writes, so a form login can answer a 303 to its login page and a token API can answer a 401 with a `WWW-Authenticate` header, each through the same response pipeline as every other endpoint.
10
+ - `Liberty::Error`, the base for every error Liberty raises, and `Liberty::AbstractMethodError`, raised when an authenticator leaves one of its two answers unimplemented.
11
+ - `Liberty::Authenticators::Public`, the authenticator for open routes. It finds no principal and never challenges.
12
+ - `Endpoint#principal`, whoever the authenticator found for the request, or `nil`.
13
+
14
+ ### Kept
15
+
16
+ - `request.headers[:authorization]`, and empty bodies with a `content-length` for `HEAD` requests, both from 0.4.0.
17
+
1
18
  ## [0.4.0] - 2026-09-15
2
19
 
3
20
  ### Breaking changes
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- liberty (0.4.0)
4
+ liberty (0.5.0)
5
5
  json (~> 2.3)
6
6
  mustermann (~> 4.0)
7
7
  mustermann-contrib (~> 4.0)
data/README.md CHANGED
@@ -37,10 +37,13 @@ That said, Liberty does not use convention. It relies on classes declaratively r
37
37
 
38
38
  ## Usage
39
39
 
40
- Liberty consists of four top level classes:
40
+ Liberty consists of these top level classes:
41
41
  * Endpoint
42
+ * Authenticator
43
+ * Authenticators::Public
42
44
  * CORS
43
45
  * Application (private)
46
+ * EndpointBuilder (private)
44
47
  * Router (private)
45
48
 
46
49
  Inherit from the `Endpoint` class to create class that responds to a single type of request.
@@ -48,15 +51,7 @@ Here's an example for an HTTP get to the `/hello` route, which returns a hello w
48
51
 
49
52
  ```ruby
50
53
  class MyEndpoint < Liberty::Endpoint
51
- responds_to :get, '/hello'
52
-
53
- def authenticated?
54
- true
55
- end
56
-
57
- def authorized?
58
- true
59
- end
54
+ responds_to :get, '/hello', authenticated_by: Liberty::Authenticators::Public
60
55
 
61
56
  def status
62
57
  200
@@ -70,7 +65,8 @@ end
70
65
 
71
66
  Using `responds_to` as in the example above registers a class to receive traffic on that route
72
67
  with our incredibly fast `Router`. You shouldn't ever need to use the `Router` directly. Just
73
- use `responds_to` to register your route.
68
+ use `responds_to` to register your route. Every route names its authenticator; `Public` is the
69
+ one Liberty ships for routes open to anyone. See [Authentication](#authentication) below.
74
70
 
75
71
  The `Application` class is another private class that turns each `Endpoint` class into a
76
72
  Rack application. This is also a class you won't use directly.
@@ -91,49 +87,122 @@ end
91
87
  If you configure your CORS headers before you launch your application, `Endpoints` will
92
88
  automatically respond with the right headers.
93
89
 
94
- ### Authentication & Authorization
90
+ ### Authentication
91
+
92
+ Every route names its authenticator:
93
+
94
+ ```ruby
95
+ class Journal < Liberty::Endpoint
96
+ responds_to :get, "/journal", authenticated_by: Authenticators::Session
97
+ end
98
+ ```
99
+
100
+ `authenticated_by:` is required. A route that does not name one fails when the class loads, so
101
+ nothing is exposed by omission. To open a route to anyone, name `Liberty::Authenticators::Public`.
102
+
103
+ An authenticator is a subclass of `Liberty::Authenticator` that answers two
104
+ questions:
105
+
106
+ - `principal`: who the request is from, or `nil`. The principal is whoever the request has been
107
+ authenticated as, whether a user, a service account, or an API client. It is any object your
108
+ application chooses.
109
+ - `challenge_endpoint_class`: the endpoint class that answers when there is no principal, or
110
+ `nil` to admit the request anyway.
95
111
 
96
- Every `Endpoint` has two hooks: `#authenticated?` and `#authorized?`. Both default to `false`,
97
- so an endpoint that does not implement them responds with `401 Authentication required`. Override
98
- them to plug in your own auth. Liberty checks `#authenticated?` first and responds with a 401 when
99
- it returns `false`. It then checks `#authorized?` and responds with `403 Forbidden` when that
100
- returns `false`. Your endpoint's status, headers, and content are only consulted when both return
101
- `true`. The raw `Authorization` header is available as `request.headers[:authorization]`.
112
+ The base class raises on both until you override them, so an authenticator cannot admit anyone
113
+ by omission either.
102
114
 
103
- A 401 response should tell the client how to authenticate. Override `#www_authenticate_header`
104
- to return the challenge, such as `Bearer realm="api"`, and Liberty adds it as the
105
- `WWW-Authenticate` header on 401 responses. When it returns `nil` the header is omitted.
115
+ Liberty builds a new authenticator for every request and injects the request, available as
116
+ `request`, the same way it builds your endpoint. Every name ending in `_class` follows one rule
117
+ throughout Liberty: it names a class that Liberty instantiates for you. With a principal, Liberty
118
+ builds your endpoint. Without one, it builds the challenge instead. Either way, the endpoint
119
+ receives the request and the principal, available as `principal`, and its answers go through the
120
+ same response pipeline, so `HEAD` requests, `content-length`, and `content-type` are handled once.
106
121
 
107
- Here's an example of how to use `#authenticated?` and `#authorized?`:
122
+ Liberty ships no authenticator but `Public`, and no 401 or 403 of its own. What a request without
123
+ a principal sees is the application's to decide: a login page for a form, a `WWW-Authenticate`
124
+ challenge for a token.
125
+
126
+ Here's a form login backed by a session. The request is injected after construction, so the
127
+ authenticator takes its repository through its own initializer with a production default, and a
128
+ test can hand it a fake:
108
129
 
109
130
  ```ruby
110
- class YourEndpoint < Liberty::Endpoint
111
- responds_to :get, "/your_endpoint"
131
+ module Authenticators
132
+ class Session < Liberty::Authenticator
133
+ def initialize(users: UsersRepository.new)
134
+ @users = users
135
+ end
112
136
 
113
- def authenticated?
114
- !current_user.nil?
115
- end
137
+ def principal
138
+ @users.find(request.env["rack.session"][:user_id])
139
+ end
116
140
 
117
- def www_authenticate_header
118
- 'Bearer realm="api"'
141
+ def challenge_endpoint_class = RedirectToLogin
119
142
  end
143
+ end
120
144
 
121
- def authorized?
122
- current_user.admin?
123
- end
145
+ class RedirectToLogin < Liberty::Endpoint
146
+ def status = 303
147
+
148
+ def headers = {"location" => "/login"}
149
+ end
124
150
 
125
- def current_user
126
- @current_user ||= Sessions.find_by_token(bearer_token)
151
+ class Journal < Liberty::Endpoint
152
+ responds_to :get, "/journal", authenticated_by: Authenticators::Session
153
+
154
+ def html = "<h1>Welcome, #{principal.name}</h1>"
155
+ end
156
+ ```
157
+
158
+ Here's a bearer token. The credential arrives in the `Authorization` header, which every request
159
+ exposes as `request.headers[:authorization]`. Despite its name, that header carries a credential
160
+ that has not been checked yet. Checking it is the authenticator's job:
161
+
162
+ ```ruby
163
+ module Authenticators
164
+ class Token < Liberty::Authenticator
165
+ def initialize(sessions: ApiSessionsRepository.new)
166
+ @sessions = sessions
167
+ end
168
+
169
+ def principal
170
+ @sessions.find_by_token(bearer_token)
171
+ end
172
+
173
+ def challenge_endpoint_class = TokenChallenge
174
+
175
+ private
176
+
177
+ def bearer_token
178
+ request.headers[:authorization].to_s.delete_prefix("Bearer ")
179
+ end
127
180
  end
181
+ end
182
+
183
+ class TokenChallenge < Liberty::Endpoint
184
+ def status = 401
128
185
 
129
- private
186
+ def headers = {"www-authenticate" => 'Bearer realm="api"'}
130
187
 
131
- def bearer_token
132
- request.headers[:authorization].to_s.delete_prefix("Bearer ")
188
+ def text = "Authentication required"
189
+ end
190
+ ```
191
+
192
+ A page that anyone may see, but that still wants to know a signed-in user, is a session
193
+ authenticator that never challenges:
194
+
195
+ ```ruby
196
+ module Authenticators
197
+ class Optional < Session
198
+ def challenge_endpoint_class = nil
133
199
  end
134
200
  end
135
201
  ```
136
202
 
203
+ Authorization is the endpoint's own answer. When the principal may not do what the request asks,
204
+ the endpoint responds with the status and content it chooses, such as a 403.
205
+
137
206
  ## Installation
138
207
 
139
208
  Install the gem and add to the application's Gemfile by executing:
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "forbidden_response"
4
- require_relative "unauthenticated_response"
5
-
6
3
  module Liberty
7
4
  module Adapters
8
5
  class Response
@@ -20,22 +17,11 @@ module Liberty
20
17
  end
21
18
 
22
19
  def to_rack_response
23
- return unauthenticated_response unless endpoint.authenticated?
24
- return forbidden_response unless endpoint.authorized?
25
-
26
20
  [status, headers, rack_body]
27
21
  end
28
22
 
29
23
  private
30
24
 
31
- def unauthenticated_response
32
- UnauthenticatedResponse.new(endpoint).to_rack_response
33
- end
34
-
35
- def forbidden_response
36
- ForbiddenResponse.new(endpoint).to_rack_response
37
- end
38
-
39
25
  def status
40
26
  endpoint.status
41
27
  end
@@ -2,13 +2,15 @@
2
2
 
3
3
  require_relative "adapters/request"
4
4
  require_relative "adapters/response"
5
+ require_relative "endpoint_builder"
5
6
 
6
7
  module Liberty
7
8
  class Application
8
- attr_reader :endpoint_class
9
+ attr_reader :endpoint_class, :authenticator_class
9
10
 
10
- def initialize(endpoint_class:)
11
+ def initialize(endpoint_class:, authenticator_class:)
11
12
  @endpoint_class = endpoint_class
13
+ @authenticator_class = authenticator_class
12
14
  end
13
15
 
14
16
  def call(env)
@@ -18,15 +20,19 @@ module Liberty
18
20
  private
19
21
 
20
22
  def response(env)
21
- Adapters::Response.new(endpoint(request(env)))
23
+ Adapters::Response.new(endpoint(env))
22
24
  end
23
25
 
24
26
  def request(env)
25
27
  Adapters::Request.new(env)
26
28
  end
27
29
 
28
- def endpoint(request)
29
- endpoint_class.new.tap { |endpoint| endpoint.inject(request: request) }
30
+ def endpoint(env)
31
+ EndpointBuilder.new(
32
+ request: request(env),
33
+ endpoint_class: endpoint_class,
34
+ authenticator_class: authenticator_class
35
+ ).endpoint
30
36
  end
31
37
  end
32
38
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Liberty
6
+ # The base authenticator. Liberty builds one per request, injects the
7
+ # request, and asks two questions. Subclasses must answer both.
8
+ #
9
+ # principal:
10
+ # who the request is from, or nil
11
+ #
12
+ # challenge_endpoint_class:
13
+ # the endpoint class that answers when there is no principal, or nil
14
+ # to admit the request anyway
15
+ #
16
+ # The request is injected after construction, so a subclass is free to
17
+ # define its own initializer with default dependencies.
18
+ class Authenticator
19
+ attr_reader :request
20
+
21
+ def inject(request:)
22
+ @request = request
23
+ end
24
+
25
+ def principal
26
+ raise AbstractMethodError, "#{self.class} must implement #principal"
27
+ end
28
+
29
+ def challenge_endpoint_class
30
+ raise AbstractMethodError, "#{self.class} must implement #challenge_endpoint_class"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../authenticator"
4
+
5
+ module Liberty
6
+ module Authenticators
7
+ # The authenticator for open routes. It never finds a principal and never
8
+ # challenges, so every request is admitted with a nil principal.
9
+ class Public < Liberty::Authenticator
10
+ def principal
11
+ nil
12
+ end
13
+
14
+ def challenge_endpoint_class
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -4,14 +4,20 @@ module Liberty
4
4
  class Endpoint
5
5
  DEFAULT_STATUS = 200
6
6
 
7
- def self.responds_to(verb, path)
8
- Liberty.add_endpoint(verb: verb, path: path, endpoint_class: self)
7
+ def self.responds_to(verb, path, authenticated_by:)
8
+ Liberty.add_endpoint(
9
+ verb: verb,
10
+ path: path,
11
+ endpoint_class: self,
12
+ authenticator_class: authenticated_by
13
+ )
9
14
  end
10
15
 
11
- attr_reader :request
16
+ attr_reader :request, :principal
12
17
 
13
- def inject(request:)
18
+ def inject(request:, principal: nil)
14
19
  @request = request
20
+ @principal = principal
15
21
  end
16
22
 
17
23
  def params
@@ -22,17 +28,6 @@ module Liberty
22
28
  request.headers[:preferred_media_type]
23
29
  end
24
30
 
25
- def authenticated?
26
- false
27
- end
28
-
29
- def www_authenticate_header
30
- end
31
-
32
- def authorized?
33
- false
34
- end
35
-
36
31
  def status
37
32
  DEFAULT_STATUS
38
33
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liberty
4
+ class EndpointBuilder
5
+ attr_reader :request, :endpoint_class, :authenticator_class
6
+
7
+ def initialize(request:, endpoint_class:, authenticator_class:)
8
+ @request = request
9
+ @endpoint_class = endpoint_class
10
+ @authenticator_class = authenticator_class
11
+ end
12
+
13
+ def endpoint
14
+ return build_challenge_endpoint if challenge?
15
+
16
+ build_endpoint
17
+ end
18
+
19
+ private
20
+
21
+ def build_challenge_endpoint
22
+ challenge_endpoint_class.new.tap { |endpoint| inject_dependencies(endpoint) }
23
+ end
24
+
25
+ def build_endpoint
26
+ endpoint_class.new.tap { |endpoint| inject_dependencies(endpoint) }
27
+ end
28
+
29
+ def principal
30
+ @principal ||= authenticator.principal
31
+ end
32
+
33
+ def challenge_endpoint_class
34
+ @challenge_endpoint_class ||= authenticator.challenge_endpoint_class
35
+ end
36
+
37
+ def challenge?
38
+ principal.nil? && !challenge_endpoint_class.nil?
39
+ end
40
+
41
+ def authenticator
42
+ @authenticator ||= authenticator_class.new.tap { |authenticator| authenticator.inject(request: request) }
43
+ end
44
+
45
+ def inject_dependencies(endpoint)
46
+ endpoint.inject(request: request, principal: principal)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liberty
4
+ # The base for every error Liberty raises, so an application can rescue
5
+ # them all with one clause.
6
+ class Error < StandardError
7
+ end
8
+
9
+ # Raised when a subclass leaves a method unimplemented that Liberty
10
+ # requires an answer to.
11
+ class AbstractMethodError < Error
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Liberty
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/liberty.rb CHANGED
@@ -7,8 +7,11 @@ require "rack/accept_media_types"
7
7
  require "rack/abstract_format"
8
8
 
9
9
  require_relative "liberty/version"
10
+ require_relative "liberty/errors"
10
11
  require_relative "liberty/endpoint"
12
+ require_relative "liberty/authenticators/public"
11
13
  require_relative "liberty/router"
14
+ require_relative "liberty/endpoint_builder"
12
15
  require_relative "liberty/application"
13
16
  require_relative "liberty/cors"
14
17
 
@@ -18,14 +21,15 @@ module Liberty
18
21
  end
19
22
 
20
23
  def self.middleware
21
- @middleware ||= Rack::Builder.new(Liberty.router) do
22
- use(Liberty::CORS::Middleware)
23
- use(Rack::AbstractFormat)
24
- end
24
+ @middleware ||=
25
+ Rack::Builder.new(Liberty.router) do
26
+ use(Liberty::CORS::Middleware)
27
+ use(Rack::AbstractFormat)
28
+ end
25
29
  end
26
30
 
27
- def self.add_endpoint(verb:, path:, endpoint_class:)
28
- app = Application.new(endpoint_class: endpoint_class)
31
+ def self.add_endpoint(verb:, path:, endpoint_class:, authenticator_class:)
32
+ app = Application.new(endpoint_class:, authenticator_class:)
29
33
  router.public_send(verb, path, to: app)
30
34
  end
31
35
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liberty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alan Ridlehoover
@@ -116,18 +116,19 @@ files:
116
116
  - Rakefile
117
117
  - assets/liberty.png
118
118
  - lib/liberty.rb
119
- - lib/liberty/adapters/error_response.rb
120
- - lib/liberty/adapters/forbidden_response.rb
121
119
  - lib/liberty/adapters/parsers/factory.rb
122
120
  - lib/liberty/adapters/parsers/json.rb
123
121
  - lib/liberty/adapters/parsers/null.rb
124
122
  - lib/liberty/adapters/request.rb
125
123
  - lib/liberty/adapters/response.rb
126
- - lib/liberty/adapters/unauthenticated_response.rb
127
124
  - lib/liberty/application.rb
125
+ - lib/liberty/authenticator.rb
126
+ - lib/liberty/authenticators/public.rb
128
127
  - lib/liberty/cors.rb
129
128
  - lib/liberty/cors/middleware.rb
130
129
  - lib/liberty/endpoint.rb
130
+ - lib/liberty/endpoint_builder.rb
131
+ - lib/liberty/errors.rb
131
132
  - lib/liberty/router.rb
132
133
  - lib/liberty/router/node.rb
133
134
  - lib/liberty/router/printer.rb
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Liberty
4
- module Adapters
5
- class ErrorResponse
6
- attr_reader :endpoint
7
-
8
- def initialize(endpoint)
9
- @endpoint = endpoint
10
- end
11
-
12
- def to_rack_response
13
- [status, headers, rack_body]
14
- end
15
-
16
- private
17
-
18
- def headers
19
- {
20
- Response::CONTENT_LENGTH => body.bytesize.to_s,
21
- Response::CONTENT_TYPE => Response::MIME_TYPE_TEXT
22
- }
23
- end
24
-
25
- def rack_body
26
- endpoint.request.head? ? [] : [body]
27
- end
28
- end
29
- end
30
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "error_response"
4
-
5
- module Liberty
6
- module Adapters
7
- class ForbiddenResponse < ErrorResponse
8
- private
9
-
10
- def status
11
- 403
12
- end
13
-
14
- def body
15
- "Forbidden"
16
- end
17
- end
18
- end
19
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "error_response"
4
-
5
- module Liberty
6
- module Adapters
7
- class UnauthenticatedResponse < ErrorResponse
8
- WWW_AUTHENTICATE = "www-authenticate"
9
-
10
- private
11
-
12
- def status
13
- 401
14
- end
15
-
16
- def headers
17
- challenge ? super.merge(WWW_AUTHENTICATE => challenge) : super
18
- end
19
-
20
- def challenge
21
- endpoint.www_authenticate_header
22
- end
23
-
24
- def body
25
- "Authentication required"
26
- end
27
- end
28
- end
29
- end