liberty 0.3.1 → 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: 44e1f59a2b827b9bda2ec873fca9e2592db73e23d9ad946ee673be3cca0c8d37
4
- data.tar.gz: 8ed828dddae3cc7fbce9957bce6d66a11801f13499761728ca03befd954655cf
3
+ metadata.gz: fb4d162f0b2ce1791b670b9b9093a8638737aec1c142908d321ebcba16499c4f
4
+ data.tar.gz: 7da18983c0aa557d727059daef386d429a8a1e8abeb4cd3473b8ec2ee0e76104
5
5
  SHA512:
6
- metadata.gz: 77a90f8920aef4886192435fecc60c46bdb317405ff447db995b9fd264136bd996c6b3b1123c502b7ad375addc8c2756a6dcecfeccdd3bf573b67b54e4d62703
7
- data.tar.gz: 7891e701f9c931e6e43488e2b45bdd47eb0790e4b8fd098aeb7870386460db43d36357768f1010bac54e819530bdad99dc7e26147e92e351af24a0cbb7b2ad84
6
+ metadata.gz: 9b657a059f19264ebab5a4cd059f50bf5d6c9057b700b4a6e4121ca35583ad1dd1f3cbe28988993a59f0e44713d8912a8d47699b0cbad66ae2e9ebcaae17e489
7
+ data.tar.gz: 3a6e8297c743040dc5d74f1cfbba70c13ba40a2d575e9da70859c924c0f403959b9ff13f4ec733a12e82f7b8ef1d94646d025c97fb6f749bb16202e237b56880
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
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
+
18
+ ## [0.4.0] - 2026-09-15
19
+
20
+ ### Breaking changes
21
+
22
+ - Endpoints now offer `authenticated?` and `authorized?` hooks for plugging in your own auth handlers. Both default to `false` to prevent endpoints from being exposed accidentally, so every existing endpoint responds with a 401 until it overrides them. Applications upgrading from older versions must override these methods, either to return `true` or with code that handles auth.
23
+
24
+ ### Added
25
+
26
+ - Responds with `401 Authentication required` when `authenticated?` returns `false`, and with `403 Forbidden` when `authorized?` returns `false`. The endpoint's status, headers, and content are only consulted when both return `true`.
27
+ - Adds a `www_authenticate_header` hook to endpoints. When it returns a challenge, such as `Bearer realm="api"`, 401 responses include it as the `WWW-Authenticate` header. It defaults to `nil`, which omits the header.
28
+ - Exposes the request's `Authorization` header as `request.headers[:authorization]` so endpoints can read credentials.
29
+ - Responds to `HEAD` requests that fail auth with an empty body and a `content-length` header, as the Rack specification requires.
30
+
1
31
  ## [0.3.1] - 2026-09-07
2
32
 
3
33
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- liberty (0.3.1)
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,7 +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'
54
+ responds_to :get, '/hello', authenticated_by: Liberty::Authenticators::Public
52
55
 
53
56
  def status
54
57
  200
@@ -62,7 +65,8 @@ end
62
65
 
63
66
  Using `responds_to` as in the example above registers a class to receive traffic on that route
64
67
  with our incredibly fast `Router`. You shouldn't ever need to use the `Router` directly. Just
65
- 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.
66
70
 
67
71
  The `Application` class is another private class that turns each `Endpoint` class into a
68
72
  Rack application. This is also a class you won't use directly.
@@ -83,6 +87,122 @@ end
83
87
  If you configure your CORS headers before you launch your application, `Endpoints` will
84
88
  automatically respond with the right headers.
85
89
 
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.
111
+
112
+ The base class raises on both until you override them, so an authenticator cannot admit anyone
113
+ by omission either.
114
+
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.
121
+
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:
129
+
130
+ ```ruby
131
+ module Authenticators
132
+ class Session < Liberty::Authenticator
133
+ def initialize(users: UsersRepository.new)
134
+ @users = users
135
+ end
136
+
137
+ def principal
138
+ @users.find(request.env["rack.session"][:user_id])
139
+ end
140
+
141
+ def challenge_endpoint_class = RedirectToLogin
142
+ end
143
+ end
144
+
145
+ class RedirectToLogin < Liberty::Endpoint
146
+ def status = 303
147
+
148
+ def headers = {"location" => "/login"}
149
+ end
150
+
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
180
+ end
181
+ end
182
+
183
+ class TokenChallenge < Liberty::Endpoint
184
+ def status = 401
185
+
186
+ def headers = {"www-authenticate" => 'Bearer realm="api"'}
187
+
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
199
+ end
200
+ end
201
+ ```
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
+
86
206
  ## Installation
87
207
 
88
208
  Install the gem and add to the application's Gemfile by executing:
@@ -7,6 +7,7 @@ module Liberty
7
7
  class Request
8
8
  PARSED_BODY = "parsed_body"
9
9
  ROUTER_PARAMS = "router.params"
10
+ AUTHORIZATION = "HTTP_AUTHORIZATION"
10
11
 
11
12
  attr_reader :env
12
13
 
@@ -15,10 +16,7 @@ module Liberty
15
16
  end
16
17
 
17
18
  def headers
18
- @headers ||= env ? {
19
- accept_media_types: accept_media_types,
20
- preferred_media_type: preferred_media_type
21
- } : {}
19
+ @headers ||= env ? env_headers : {}
22
20
  end
23
21
 
24
22
  def params
@@ -31,6 +29,14 @@ module Liberty
31
29
 
32
30
  private
33
31
 
32
+ def env_headers
33
+ {
34
+ accept_media_types: accept_media_types,
35
+ preferred_media_type: preferred_media_type,
36
+ authorization: env[AUTHORIZATION]
37
+ }
38
+ end
39
+
34
40
  def all_params
35
41
  form_params.merge!(body_params).merge!(url_params)
36
42
  end
@@ -73,7 +79,9 @@ module Liberty
73
79
  end
74
80
 
75
81
  def symbolize_keys(hash)
76
- hash.each_with_object({}) { |(key, value), obj| obj[key.to_sym] = value }
82
+ hash.each_with_object({}) do |(key, value), obj|
83
+ obj[key.to_sym] = value
84
+ end
77
85
  end
78
86
 
79
87
  def rack_request
@@ -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
@@ -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.3.1"
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.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alan Ridlehoover
@@ -122,9 +122,13 @@ files:
122
122
  - lib/liberty/adapters/request.rb
123
123
  - lib/liberty/adapters/response.rb
124
124
  - lib/liberty/application.rb
125
+ - lib/liberty/authenticator.rb
126
+ - lib/liberty/authenticators/public.rb
125
127
  - lib/liberty/cors.rb
126
128
  - lib/liberty/cors/middleware.rb
127
129
  - lib/liberty/endpoint.rb
130
+ - lib/liberty/endpoint_builder.rb
131
+ - lib/liberty/errors.rb
128
132
  - lib/liberty/router.rb
129
133
  - lib/liberty/router/node.rb
130
134
  - lib/liberty/router/printer.rb