strongroom-rails 0.1.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 +7 -0
- data/CHANGELOG.md +1 -0
- data/LICENSE +28 -0
- data/README.md +415 -0
- data/lib/strongroom/client.rb +207 -0
- data/lib/strongroom/errors.rb +21 -0
- data/lib/strongroom/net_http_transport.rb +207 -0
- data/lib/strongroom/rails/account.rb +43 -0
- data/lib/strongroom/rails/authentication.rb +114 -0
- data/lib/strongroom/rails/railtie.rb +39 -0
- data/lib/strongroom/rails/registry.rb +66 -0
- data/lib/strongroom/rails/resolver.rb +78 -0
- data/lib/strongroom/rails/version.rb +7 -0
- data/lib/strongroom/rails/warden_strategy.rb +27 -0
- data/lib/strongroom/rails.rb +10 -0
- data/lib/strongroom/session.rb +24 -0
- data/lib/strongroom/token.rb +53 -0
- data/lib/strongroom.rb +8 -0
- metadata +233 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d266097d730b4561dc0e197cbfb8cb6811626b1cfea5defd21b7f018f0e39ed0
|
|
4
|
+
data.tar.gz: efc7b6166160438836119409e1c836179daa40b33913dbe4f6a9464eff9f53d3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d9200dd2cdf23a5822286500ee92e35c254d824a2d910c8039df5bfdab0f05402a51ed2dc6e601a7069db72584353d0e8a05681d2ff4b223b4040cd15f8ab6fe
|
|
7
|
+
data.tar.gz: c109f7251482e29b24b2349ff5cce04958207eaca352d90d5a7b0311d38ca0abc7e55a763e5ec7f25309b323ee092ae8b3e6b415ac675322803e12da3cc5727b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Changelog
|
data/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Seuros
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# strongroom-rails — existing-user login adapter
|
|
2
|
+
|
|
3
|
+
Rails adapter for [Strongroom](https://github.com/seuros/strongroom), a Postgres-first authentication kernel
|
|
4
|
+
that acts as your application's security coprocessor: Rails never sees password
|
|
5
|
+
hashes or session secrets. Registration, MFA, JWT minting and automatic routes
|
|
6
|
+
are deliberately absent.
|
|
7
|
+
|
|
8
|
+
> **Status: pre-production.** Strongroom has not been audited and is not approved
|
|
9
|
+
> for production traffic or real credential imports.
|
|
10
|
+
|
|
11
|
+
Requires Ruby **4.0+** and Rails **8.1+** (currently Rails 8.x).
|
|
12
|
+
|
|
13
|
+
## What ships
|
|
14
|
+
|
|
15
|
+
- [x] Namespace-bound login, canonical session lookup, explicit rotation and logout.
|
|
16
|
+
- [x] HTTPS transport (HTTP only on literal loopback/localhost), no redirects,
|
|
17
|
+
environment proxies or automatic retries. Per-I/O timeouts, a monotonic
|
|
18
|
+
whole-call `deadline:` (default 15 s; every socket read, headers included, is
|
|
19
|
+
clamped to the remaining budget) and a 64 KiB response bound.
|
|
20
|
+
- [x] Optional keep-alive and per-request memoization (see below).
|
|
21
|
+
- [x] Strict token/cookie/metadata parsing. Login confirms `/me` audience and metadata
|
|
22
|
+
before returning an authenticated session; a session issued but not confirmed
|
|
23
|
+
is revoked before the error is raised. Expiry is compared at microsecond
|
|
24
|
+
precision (strongroomd reports nanoseconds at login, PostgreSQL stores microseconds).
|
|
25
|
+
Raw tokens are not JSON, YAML or Marshal payloads.
|
|
26
|
+
- [x] Explicit Rails helpers, Secure/HttpOnly/SameSite=Lax encrypted cookies,
|
|
27
|
+
mandatory mutation intent (Rails CSRF on `ActionController::Base`, the
|
|
28
|
+
`X-Strongroom-Request` marker plus Origin policy on `ActionController::API`),
|
|
29
|
+
strict HTTP verbs and no-store responses varying on Cookie/Authorization.
|
|
30
|
+
- [x] Request-level resolver and an optional Warden strategy.
|
|
31
|
+
- [x] Trusted subject resolution into an allowlisted local model/table, followed by
|
|
32
|
+
an application-supplied active/confirmed/MFA/cohort eligibility check.
|
|
33
|
+
|
|
34
|
+
It never finds users by email, creates local accounts, dynamically constantizes
|
|
35
|
+
table names, overrides `current_user`, installs routes or falls back to legacy
|
|
36
|
+
authentication after a denial/timeout.
|
|
37
|
+
|
|
38
|
+
## Wiring an application
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "strongroom-rails", require: "strongroom/rails"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The kernel must be configured for the corresponding named accounts
|
|
45
|
+
(`[accounts.<name>]`, see the [configuration reference](https://github.com/seuros/strongroom#accountsname)).
|
|
46
|
+
Configure existing Strongroom org/app IDs, not Rails IDs.
|
|
47
|
+
Use separate namespaces/bindings for users and admins; naming a namespace
|
|
48
|
+
`admin` does not implement MFA or authorize an admin migration.
|
|
49
|
+
|
|
50
|
+
Build the registry in a Rails initializer's `to_prepare` block so reloadable
|
|
51
|
+
model classes are resolved afresh. This example deliberately leaves your
|
|
52
|
+
binding storage and eligibility policy explicit:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
Rails.application.config.to_prepare do
|
|
56
|
+
client = Strongroom::Client.new(
|
|
57
|
+
endpoint: ENV.fetch("STRONGROOM_ENDPOINT"), issuer: "https://auth.example.com", namespace: "users",
|
|
58
|
+
org_id: ENV.fetch("STRONGROOM_USERS_ORG_ID"), app_id: ENV.fetch("STRONGROOM_USERS_APP_ID")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
users = Strongroom::Rails::Account.new(
|
|
62
|
+
client: client, table: "users", model: User,
|
|
63
|
+
# Your store must uniquely bind issuer + namespace + org + app + kernel
|
|
64
|
+
# user_id to a pre-existing local record. Missing binding returns nil.
|
|
65
|
+
resolve: ->(subject) { YourTrustedBindingStore.lookup(subject)&.record },
|
|
66
|
+
# Implement this explicitly: active, confirmed, password-only, enrolled
|
|
67
|
+
# in the migrated cohort, and authoritative credential/account state.
|
|
68
|
+
eligible: ->(record) { YourLoginCohortPolicy.allowed?(record) },
|
|
69
|
+
register: false
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
Rails.application.config.x.strongroom_accounts = Strongroom::Rails::Registry.new(users)
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`issuer:` is the stable identity your bindings are keyed by; it is never derived
|
|
77
|
+
from `endpoint:` (the network address), so moving strongroomd to another host or port
|
|
78
|
+
does not orphan existing bindings. Use an absolute URI (`https://...` or `urn:...`)
|
|
79
|
+
and never change it once bindings exist. A custom `transport:` must report the
|
|
80
|
+
same `endpoint` as the client.
|
|
81
|
+
|
|
82
|
+
`YourTrustedBindingStore` and `YourLoginCohortPolicy` are intentional placeholders,
|
|
83
|
+
not supplied models or a default policy. The resolver receives a frozen,
|
|
84
|
+
secret-free `Strongroom::Subject` with `issuer`, `namespace`, `org_id`, `app_id`,
|
|
85
|
+
`user_id`; it receives no email, password, local ID or bearer token.
|
|
86
|
+
Binding creation/import needs a trusted, collision-rejecting workflow and a
|
|
87
|
+
database uniqueness constraint. Never use a browser-supplied local ID or email
|
|
88
|
+
as linking authority. Resolver exceptions become service failures, not fallback.
|
|
89
|
+
Eligibility must return exactly `true` to authorize a record.
|
|
90
|
+
|
|
91
|
+
The Railtie verifies this at boot (after `to_prepare` blocks run, and again on
|
|
92
|
+
every code reload): a missing or wrong-typed `config.x.strongroom_accounts`, or an
|
|
93
|
+
app without `ActionDispatch::Cookies` middleware, stops the boot with a specific
|
|
94
|
+
`Strongroom::ConfigurationError` instead of failing on the first request. Account
|
|
95
|
+
and registry errors name the offending setting. Processes that never
|
|
96
|
+
authenticate (for example an asset build without credentials) can opt out with
|
|
97
|
+
`config.strongroom.verify_on_boot = false`.
|
|
98
|
+
|
|
99
|
+
Add additional `Account` objects to the registry for other approved namespaces.
|
|
100
|
+
At most 16 are supported; names, tables and same-issuer audiences must be distinct.
|
|
101
|
+
Pass actual model classes, never class names from parameters/configuration.
|
|
102
|
+
`register` defaults false; true records future policy only and adds no route.
|
|
103
|
+
|
|
104
|
+
Controllers opt in explicitly and keep their own frontend response contract:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class Authentication::SessionsController < ApplicationController
|
|
108
|
+
include Strongroom::Rails::Authentication
|
|
109
|
+
protect_from_forgery with: :exception
|
|
110
|
+
rate_limit to: 10, within: 3.minutes, by: -> { request.remote_ip }, only: :create
|
|
111
|
+
|
|
112
|
+
rescue_from Strongroom::Unauthorized do
|
|
113
|
+
head :unauthorized
|
|
114
|
+
end
|
|
115
|
+
rescue_from Strongroom::Unavailable, Strongroom::ProtocolError do
|
|
116
|
+
head :service_unavailable
|
|
117
|
+
end
|
|
118
|
+
rescue_from Strongroom::RateLimited do |error|
|
|
119
|
+
response.set_header("Retry-After", error.retry_after.to_s) if error.retry_after
|
|
120
|
+
head(error.is_a?(Strongroom::Busy) ? :service_unavailable : :too_many_requests)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def create # POST; preserve your approved route/serializer.
|
|
124
|
+
user = strongroom_login(namespace: :users, identity_kind: "email",
|
|
125
|
+
identity: params.require(:email), password: params.require(:password))
|
|
126
|
+
render json: {id: user.id}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def show # GET; checks canonical session and local eligibility every time.
|
|
130
|
+
user = strongroom_current_account(namespace: :users)
|
|
131
|
+
user ? render(json: {id: user.id}) : head(:unauthorized)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def destroy # DELETE; verified CSRF required, including absent/invalid credentials.
|
|
135
|
+
strongroom_logout(namespace: :users)
|
|
136
|
+
head :no_content
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Add your own per-IP rate limit before `strongroom_login`.** Rails is strongroomd's
|
|
142
|
+
only client, so every login reaches strongroomd from the Rails server's address:
|
|
143
|
+
strongroomd's global and per-identity budgets cannot tell one abusive client from
|
|
144
|
+
all your users, and a single client can drain the global budget (locking
|
|
145
|
+
everyone out) or spray many identities. Rails 8's `rate_limit` (above) refuses
|
|
146
|
+
excess attempts before any upstream call. Behind a load balancer, configure
|
|
147
|
+
`config.action_dispatch.trusted_proxies` so `request.remote_ip` is the client,
|
|
148
|
+
and use a cache store shared by all app instances (the limiter's counters live
|
|
149
|
+
in `cache_store`). Keep the limit on every action that calls `strongroom_login`.
|
|
150
|
+
|
|
151
|
+
`strongroom_rotate(namespace: :users)` is an explicit POST helper returning the
|
|
152
|
+
validated local record and replacing the encrypted cookie. GET lookup never
|
|
153
|
+
calls rotation or writes/deletes an authentication cookie.
|
|
154
|
+
|
|
155
|
+
### Mutation intent: `ActionController::Base` vs `ActionController::API`
|
|
156
|
+
|
|
157
|
+
On controllers with Rails forgery protection (`ActionController::Base`), login,
|
|
158
|
+
rotation and logout require a verified Rails CSRF token; the helpers refuse
|
|
159
|
+
mutations if protection is disabled or unverified, even with the intent header.
|
|
160
|
+
|
|
161
|
+
`ActionController::API` has no forgery protection, so the concern applies the
|
|
162
|
+
same policy strongroomd enforces instead:
|
|
163
|
+
|
|
164
|
+
- exactly one `X-Strongroom-Request: 1` header (the kernel's intent marker; HTML
|
|
165
|
+
forms cannot set it, and cross-origin `fetch` must pass a CORS preflight);
|
|
166
|
+
- if `Origin` is present it must equal the app's own origin (`request.base_url`)
|
|
167
|
+
or be listed with `strongroom_trust_origins`; `null` and unlisted origins are refused;
|
|
168
|
+
- browser requests carrying Fetch Metadata (`Sec-Fetch-Site`) without `Origin`
|
|
169
|
+
are refused. Requests with neither header (server-to-server) need only the marker.
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class Api::SessionsController < ActionController::API
|
|
173
|
+
include Strongroom::Rails::Authentication
|
|
174
|
+
strongroom_trust_origins "https://dashboard.example.com", "https://admin.example.com"
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Trusted origins are exact canonical origins (HTTPS; HTTP only on loopback), no
|
|
179
|
+
wildcards. An API-mode app must also provide:
|
|
180
|
+
|
|
181
|
+
- `config.middleware.use ActionDispatch::Cookies` (absent by default in
|
|
182
|
+
`api_only` apps); the helpers read and write the encrypted cookie through
|
|
183
|
+
`request.cookie_jar`, so `ActionController::Cookies` is optional;
|
|
184
|
+
- a CORS policy that allows `X-Strongroom-Request` with `credentials: true` only for
|
|
185
|
+
those same origins, and never reflects arbitrary origins;
|
|
186
|
+
- frontends that send `credentials: "include"` and the header on every mutation;
|
|
187
|
+
- HTTPS and a cookie scope that fits the deployment (Secure, SameSite=Lax:
|
|
188
|
+
same-site subdomains work, cross-site frontends do not).
|
|
189
|
+
|
|
190
|
+
Refusals raise `ActionController::InvalidAuthenticityToken` (422) on both paths.
|
|
191
|
+
The Railtie maps `Strongroom::InvalidRequest` to 400, `Unauthorized` to 401,
|
|
192
|
+
`RateLimited` to 429 and `Busy`/`ProtocolError`/`Unavailable` to 503 for
|
|
193
|
+
controllers that do not `rescue_from` them.
|
|
194
|
+
|
|
195
|
+
### Request resolver and Warden
|
|
196
|
+
|
|
197
|
+
`Strongroom::Rails::Resolver.new(request)` accepts an `ActionDispatch::Request`,
|
|
198
|
+
a `Rack::Request` or a Rack env and answers narrow questions with the same
|
|
199
|
+
precedence as the helpers (any `Authorization` header suppresses the cookie):
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
resolver = Strongroom::Rails::Resolver.new(request)
|
|
203
|
+
resolver.subject(:users) # Strongroom::Subject or nil (canonical, no local binding)
|
|
204
|
+
resolver.account(:users) # eligible local record or nil
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Service failures raise; they never become `nil`. If Warden is loaded when the
|
|
208
|
+
app finishes booting, the gem registers `Strongroom::Rails::WardenStrategy` as
|
|
209
|
+
`:strongroom` (or `require "strongroom/rails/warden_strategy"` explicitly). Map Warden
|
|
210
|
+
scopes to namespaces in the registry:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
Strongroom::Rails::Registry.new(users, admins, warden_scopes: {user: :users, admin: :admin})
|
|
214
|
+
|
|
215
|
+
warden.authenticate(:strongroom, :jwt, scope: :user)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Unmapped scopes fall back to a namespace of the same name. The strategy is only
|
|
219
|
+
`valid?` when the credential selected by precedence is a Strongroom token, so a
|
|
220
|
+
request still carrying a legacy `Bearer <JWT>` falls through to the next
|
|
221
|
+
strategy. A Strongroom credential that is refused halts the chain (`fail!`): there
|
|
222
|
+
is no fallback to another strategy after a Strongroom denial or outage. The
|
|
223
|
+
strategy never stores the user in the Rack session (`store?` is false), so every
|
|
224
|
+
request is checked against strongroomd and local eligibility. If you keep
|
|
225
|
+
`warden-jwt_auth`, remove migrated routes from its `dispatch_requests`, or it
|
|
226
|
+
will mint a JWT for a user Warden authenticated through Strongroom.
|
|
227
|
+
|
|
228
|
+
Browser cookie names are `strongroom_users`, `strongroom_admin`, etc. They contain a
|
|
229
|
+
Rails-encrypted token, never a local user ID or a JWT. Secure is always enabled:
|
|
230
|
+
serve the Rails app over HTTPS, including in browser development. The upstream
|
|
231
|
+
cookie base defaults to `__Host-strongroom_session`, the kernel's default
|
|
232
|
+
`[cookies] name`; set `cookie_base:` to the kernel's configured name when it
|
|
233
|
+
differs. Upstream Set-Cookie attributes are never relayed.
|
|
234
|
+
|
|
235
|
+
For lookups and rotation, if Authorization is present it must be a canonical
|
|
236
|
+
Strongroom Bearer token; invalid, empty or coalesced headers suppress cookie
|
|
237
|
+
fallback. This does **not** accept legacy application JWTs. Every valid credential
|
|
238
|
+
is checked against the configured namespace endpoint/audience; the registry
|
|
239
|
+
never selects a realm from JWT claims.
|
|
240
|
+
|
|
241
|
+
No successful browser cookie is issued until remote authentication, audience
|
|
242
|
+
verification, trusted mapping and local eligibility all succeed. Mapping failures
|
|
243
|
+
after issuance revoke the new session upstream on a best-effort basis; the
|
|
244
|
+
original error (for example the 401 for an ineligible user) is always the one
|
|
245
|
+
raised, and a session that could not be revoked was never delivered and expires
|
|
246
|
+
at its TTL. No automatic retry is safe after an ambiguous login/rotation outcome.
|
|
247
|
+
|
|
248
|
+
Logout revokes every Strongroom credential the request carries: the Bearer token
|
|
249
|
+
and the cookie's token, even when the Authorization header is foreign (a SPA
|
|
250
|
+
still sending `Bearer <JWT>`). It never reports success for a credential it did
|
|
251
|
+
not revoke: an upstream failure keeps the cookie and raises, and a request
|
|
252
|
+
whose only credential is a foreign Authorization header gets `Unauthorized`.
|
|
253
|
+
A request with no credentials at all is an idempotent no-op.
|
|
254
|
+
|
|
255
|
+
strongroomd answers 403 when its intent/Origin policy refuses the Rails server
|
|
256
|
+
itself; that is a deployment misconfiguration, so it raises `ProtocolError`
|
|
257
|
+
(503), never `Unauthorized`.
|
|
258
|
+
|
|
259
|
+
### Connection reuse and per-request memoization
|
|
260
|
+
|
|
261
|
+
Within one request, session lookup and local resolution run once per namespace:
|
|
262
|
+
the helpers, `Resolver` and the Warden strategy share a memo stored in the Rack
|
|
263
|
+
env. `strongroom_login`, `strongroom_rotate` and `strongroom_logout` clear it, so a lookup
|
|
264
|
+
after a mutation in the same request sees the new state. Nothing is cached
|
|
265
|
+
across requests.
|
|
266
|
+
|
|
267
|
+
By default every call opens and closes its own connection. To reuse TLS
|
|
268
|
+
connections, pass a keep-alive transport:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
transport = Strongroom::NetHTTPTransport.new(endpoint: ENV.fetch("STRONGROOM_ENDPOINT"), keep_alive: true)
|
|
272
|
+
Strongroom::Client.new(endpoint: ENV.fetch("STRONGROOM_ENDPOINT"), issuer: "https://auth.example.com",
|
|
273
|
+
namespace: "users", org_id: ..., app_id: ..., transport: transport)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Each in-flight call checks out its own connection (threads and fibers never share
|
|
277
|
+
one); up to `max_idle:` (default 4) idle connections are kept and dropped after
|
|
278
|
+
`keep_alive_timeout:` (default 2 s) of idleness. A connection that failed, timed
|
|
279
|
+
out or was cut short is never reused. Retries stay disabled: a request that was
|
|
280
|
+
sent on a connection the server then dropped raises `Unavailable`. Pools are
|
|
281
|
+
per process; a forked child never touches its parent's sockets. Call
|
|
282
|
+
`transport.close` to drop idle connections at shutdown.
|
|
283
|
+
|
|
284
|
+
## Running the daemon
|
|
285
|
+
|
|
286
|
+
This gem is only the client: it ships no binary and needs none to install. The
|
|
287
|
+
daemon comes separately, and the two meet over HTTP:
|
|
288
|
+
|
|
289
|
+
- The [`strongroomd`](https://rubygems.org/gems/strongroomd) gem: platform gems
|
|
290
|
+
bundle `strongroomd` and `strongroom-admin`, checked against a SHA-256 manifest
|
|
291
|
+
before they run.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
gem "strongroomd"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```sh
|
|
298
|
+
bundle exec strongroom-admin migrate
|
|
299
|
+
bundle exec strongroomd
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- The crate: `cargo install strongroomd --locked`.
|
|
303
|
+
- A source build: `cargo build --release --locked -p strongroomd` at the repository root.
|
|
304
|
+
|
|
305
|
+
The two gems are versioned separately.
|
|
306
|
+
|
|
307
|
+
## Security / rollout boundaries
|
|
308
|
+
|
|
309
|
+
- Keep direct backend access restricted and TLS/service networking trusted.
|
|
310
|
+
Local eligibility checks do not synchronize kernel account state or revoke
|
|
311
|
+
all remote sessions. Password changes/resets, disablement and MFA enablement
|
|
312
|
+
still need authoritative credential/state updates and invalidation.
|
|
313
|
+
- This release is an existing, confirmed, password-only cohort. Real admins,
|
|
314
|
+
MFA-required/unverified accounts and registration remain on their explicitly
|
|
315
|
+
selected legacy owner. Do not use failure fallback or mislabel MFA state.
|
|
316
|
+
- Define old-cookie/JWT retirement and canonical-session-bound compatibility
|
|
317
|
+
before cutting an existing application over. This gem does not preserve existing JWT/frontend
|
|
318
|
+
contracts automatically or share Rails signing secrets with Strongroom.
|
|
319
|
+
- No process-wide principal cache is used. Resolver/eligibility callbacks must
|
|
320
|
+
query fresh authoritative state; do not cache past disablement/MFA changes.
|
|
321
|
+
- Tokens have explicit `reveal`, redacted inspection/JSON and no Marshal support.
|
|
322
|
+
Ruby/parser/network/framework copies cannot be reliably erased. Do not log
|
|
323
|
+
headers, cookie jars, passwords, transport bodies or explicit token strings.
|
|
324
|
+
The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
|
|
325
|
+
- Registration/mail/recovery, bulk mapping orchestration, session families,
|
|
326
|
+
remote admin operations, deployment SLOs and production approval are not shipped.
|
|
327
|
+
|
|
328
|
+
## Local checks (no installs, network or database required)
|
|
329
|
+
|
|
330
|
+
Tests use `padded_cell` 0.2 with Minitest 6. With compatible gems already installed:
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
rake test
|
|
334
|
+
# With a resolved bundle:
|
|
335
|
+
bundle exec rake test
|
|
336
|
+
# Direct runner, e.g. to choose a seed:
|
|
337
|
+
bundle exec ruby -Ilib:test -S padded_cell -j 2 --timeout 30 --seed 1981
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The Rake task uses two workers, fork-per-test isolation and a 30-second timeout.
|
|
341
|
+
Timing data under `tmp/padded_cell/` is ignored by Git.
|
|
342
|
+
|
|
343
|
+
Tests use a mock upstream protocol and real in-memory Rails/Rack requests with
|
|
344
|
+
synthetic local records (Base and API controllers, Warden). The transport is
|
|
345
|
+
tested against real loopback servers: plain HTTP, a self-signed TLS certificate
|
|
346
|
+
generated per test, trickled responses, oversized bodies, redirects, ignored
|
|
347
|
+
environment proxies, keep-alive reuse and fork safety. Boot validation is tested
|
|
348
|
+
by booting fresh Rails processes. They do not prove the Rust scoped SQL,
|
|
349
|
+
actual PostgreSQL bindings or an existing application's routes or cutover.
|
|
350
|
+
The separate Rust/PostgreSQL regressions do not exercise this gem.
|
|
351
|
+
|
|
352
|
+
Package locally:
|
|
353
|
+
|
|
354
|
+
```sh
|
|
355
|
+
rake gem:build # pkg/strongroom-rails-<version>.gem
|
|
356
|
+
rake gems:push # pull the strongroom-rails-v<version> GitHub release, verify it, push to RubyGems
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Packaged consumer check
|
|
360
|
+
|
|
361
|
+
```sh
|
|
362
|
+
rake test:package
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Builds and verifies a fresh `.gem`, installs it only in an owned temporary gem
|
|
366
|
+
home, and resolves a separate consumer bundle with `bundle lock --local`.
|
|
367
|
+
Runs the ordinary suite plus an installed-entry-point check using `padded_cell`.
|
|
368
|
+
|
|
369
|
+
The runner's working directory is the temporary consumer, not this checkout:
|
|
370
|
+
`padded_cell` normally adds the current project's `lib/` to its load path. An
|
|
371
|
+
explicit assertion checks that client and Rails code came from the installed
|
|
372
|
+
archive, not the source tree. No dependency downloads or global installs.
|
|
373
|
+
Temporary package, bundle, timing files and gem home are removed afterward.
|
|
374
|
+
|
|
375
|
+
## Real backend check
|
|
376
|
+
|
|
377
|
+
The opt-in harness is `integration/run.rb`; ordinary `rake test` does not load it.
|
|
378
|
+
It needs the development-only `pg` gem and an explicitly supplied **disposable**
|
|
379
|
+
PostgreSQL 18+ database. No database URL is inferred from the app or production
|
|
380
|
+
environment.
|
|
381
|
+
|
|
382
|
+
Build both real binaries first (`cargo build --release --locked -p strongroomd` at the
|
|
383
|
+
repository root). From this gem directory, with disposable PostgreSQL running:
|
|
384
|
+
|
|
385
|
+
```sh
|
|
386
|
+
STRONGROOM_TEST_DATABASE_URL="$DISPOSABLE_DATABASE_URL" rake test:integration
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`STRONGROOM_TEST_BIN_DIR` can override the default repository `target/release`
|
|
390
|
+
directory; it may also name the RubyGems launchers of an installed `strongroomd` platform gem
|
|
391
|
+
(`$(ruby -e 'print Gem.bindir')`). The harness:
|
|
392
|
+
|
|
393
|
+
- Migrates a unique schema with the actual `strongroom-admin migrate`, then starts
|
|
394
|
+
the actual public binary on loopback with a private config, the kernel's default
|
|
395
|
+
`__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
|
|
396
|
+
daemon exits, the failure carries its stderr (database URL redacted).
|
|
397
|
+
- Seeds only synthetic org/app bindings, then uses the actual admin CLI to
|
|
398
|
+
provision two distinct, confirmed password-only principals with the same email.
|
|
399
|
+
The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
|
|
400
|
+
- Installs a fresh package in a temporary consumer bundle, then uses its real
|
|
401
|
+
Net::HTTP client for login, audience isolation, rotation, reuse detection,
|
|
402
|
+
wrong passwords, the identity rate limit and canonical logout. Login and `/me`
|
|
403
|
+
must report the same stored microsecond expiry, which only a nanosecond host
|
|
404
|
+
clock (Linux) can contradict.
|
|
405
|
+
- Exercises the Rails helpers through in-memory Rack/HTTPS requests, encrypted
|
|
406
|
+
cookies, CSRF and a full-scope trusted mapping into synthetic local records.
|
|
407
|
+
It does not validate a real ActiveRecord binding store, browser TLS or an existing application.
|
|
408
|
+
- Checks the database for two bcrypt upgrades, two rotations and no live sessions,
|
|
409
|
+
and the daemon's `/admin/metrics` for the exact login outcomes.
|
|
410
|
+
- Bounds subprocesses and kills/reaps its owned process groups, drops its schema
|
|
411
|
+
and removes temporary files on completion or handled failure/interruption.
|
|
412
|
+
The caller remains responsible for stopping the supplied PostgreSQL server.
|
|
413
|
+
|
|
414
|
+
Offline consumer checks are not a successful real-backend lifecycle; this
|
|
415
|
+
harness is the end-to-end gate.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Strongroom
|
|
6
|
+
class Client
|
|
7
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
8
|
+
TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})\z/
|
|
9
|
+
MICROSECOND = Rational(1, 1_000_000)
|
|
10
|
+
ISSUER = /\A[a-z][a-z0-9+.-]*:[!-~]{1,255}\z/
|
|
11
|
+
attr_reader :endpoint, :issuer, :namespace, :org_id, :app_id, :cookie_name
|
|
12
|
+
|
|
13
|
+
def initialize(endpoint:, issuer:, namespace:, org_id:, app_id:, cookie_base: "__Host-strongroom_session", transport: nil)
|
|
14
|
+
@endpoint = NetHTTPTransport.endpoint(endpoint)
|
|
15
|
+
unless issuer.is_a?(String) && issuer.match?(ISSUER)
|
|
16
|
+
raise ConfigurationError, "explicit issuer URI required (stable binding identity, not the network endpoint)"
|
|
17
|
+
end
|
|
18
|
+
@issuer = issuer.dup.freeze
|
|
19
|
+
@namespace = namespace.to_s.dup.freeze
|
|
20
|
+
unless @namespace.match?(/\A[a-z][a-z0-9_]{0,31}\z/) &&
|
|
21
|
+
cookie_base.is_a?(String) && cookie_base.match?(/\A[A-Za-z0-9_.-]{1,128}\z/)
|
|
22
|
+
raise ConfigurationError, "invalid account namespace or cookie base"
|
|
23
|
+
end
|
|
24
|
+
@org_id, @app_id = [org_id, app_id].map { |value| configured_uuid(value) }
|
|
25
|
+
@cookie_name = "#{cookie_base}_#{@namespace}".freeze
|
|
26
|
+
@transport = transport || NetHTTPTransport.new(endpoint: @endpoint)
|
|
27
|
+
unless serves_endpoint?(@transport)
|
|
28
|
+
raise ConfigurationError, "transport endpoint does not match the client endpoint"
|
|
29
|
+
end
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inspect = "#<Strongroom::Client issuer=#{issuer} endpoint=#{endpoint} namespace=#{namespace}>"
|
|
34
|
+
|
|
35
|
+
def login(identity:, password:, identity_kind: "email")
|
|
36
|
+
unless [identity, password, identity_kind].all? { |value| value.is_a?(String) }
|
|
37
|
+
raise InvalidRequest, "credentials must be strings"
|
|
38
|
+
end
|
|
39
|
+
body = JSON.generate(identity_kind: identity_kind, identity: identity, password: password)
|
|
40
|
+
raise InvalidRequest, "login request too large" if body.bytesize > 16_384
|
|
41
|
+
response = request(:post, "auth/login", body: body)
|
|
42
|
+
issue(response) do |token|
|
|
43
|
+
declared = object(response, %w[session_id user_id expires_at])
|
|
44
|
+
current = session(token: token)
|
|
45
|
+
raise ProtocolError, "inconsistent authentication response" unless same_login?(declared, current)
|
|
46
|
+
current
|
|
47
|
+
end
|
|
48
|
+
rescue JSON::GeneratorError
|
|
49
|
+
raise InvalidRequest, "invalid login payload", cause: nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def session(token:)
|
|
53
|
+
metadata(request(:get, "me", token: Token.coerce(token)))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rotate(token:)
|
|
57
|
+
response = request(:post, "auth/rotate", token: Token.coerce(token))
|
|
58
|
+
issue(response) do |successor|
|
|
59
|
+
declared = metadata(response)
|
|
60
|
+
current = session(token: successor)
|
|
61
|
+
raise ProtocolError, "inconsistent rotation response" unless same_session?(declared, current)
|
|
62
|
+
current
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def logout(token:)
|
|
67
|
+
request(:delete, "auth/logout", token: Token.coerce(token), status: 204)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def discard(token:)
|
|
72
|
+
logout(token: token)
|
|
73
|
+
true
|
|
74
|
+
rescue Error
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def subject(session)
|
|
79
|
+
unless session.is_a?(Session) && session.org_id == org_id && session.app_id == app_id &&
|
|
80
|
+
session.expires_at > Time.now
|
|
81
|
+
raise Unauthorized, "authentication failed"
|
|
82
|
+
end
|
|
83
|
+
Subject.new(issuer: issuer, namespace: namespace, org_id: org_id, app_id: app_id, user_id: session.user_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def issue(response)
|
|
89
|
+
token = issued_token(response)
|
|
90
|
+
IssuedSession.new(session: yield(token), token: token)
|
|
91
|
+
rescue Error => error
|
|
92
|
+
discard(token: token) if token
|
|
93
|
+
raise error
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def same_login?(declared, current)
|
|
97
|
+
declared["session_id"] == current.session_id && declared["user_id"] == current.user_id &&
|
|
98
|
+
same_instant?(timestamp(declared["expires_at"]), current.expires_at)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def same_session?(declared, current)
|
|
102
|
+
declared.to_h.except(:expires_at) == current.to_h.except(:expires_at) &&
|
|
103
|
+
same_instant?(declared.expires_at, current.expires_at)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def same_instant?(left, right)
|
|
107
|
+
(left.to_r - right.to_r).abs < MICROSECOND
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def timestamp(stamp)
|
|
111
|
+
unless stamp.is_a?(String) && stamp.match?(TIMESTAMP)
|
|
112
|
+
raise ProtocolError, "invalid authentication response"
|
|
113
|
+
end
|
|
114
|
+
Time.iso8601(stamp).freeze
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
raise ProtocolError, "invalid authentication response", cause: nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def serves_endpoint?(transport)
|
|
120
|
+
transport.respond_to?(:call) && transport.respond_to?(:endpoint) &&
|
|
121
|
+
NetHTTPTransport.endpoint(transport.endpoint) == endpoint
|
|
122
|
+
rescue ConfigurationError
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def configured_uuid(value)
|
|
127
|
+
unless canonical_uuid?(value)
|
|
128
|
+
raise ConfigurationError, "explicit canonical org/app UUIDs required"
|
|
129
|
+
end
|
|
130
|
+
value.dup.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def canonical_uuid?(value)
|
|
134
|
+
value.is_a?(String) && value.match?(UUID) && value != "00000000-0000-0000-0000-000000000000"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def request(method, suffix, token: nil, body: nil, status: 200)
|
|
138
|
+
headers = {"Accept" => "application/json", "Accept-Encoding" => "identity",
|
|
139
|
+
"Cache-Control" => "no-store", "X-Strongroom-Request" => "1"}
|
|
140
|
+
headers["Content-Type"] = "application/json" if body
|
|
141
|
+
headers["Authorization"] = "Bearer #{token.reveal}" if token
|
|
142
|
+
response = @transport.call(method, "/accounts/#{namespace}/#{suffix}", headers: headers, body: body)
|
|
143
|
+
unless response.is_a?(TransportResponse) && response.body.bytesize <= NetHTTPTransport::MAX_RESPONSE_BYTES
|
|
144
|
+
raise ProtocolError, "invalid authentication response"
|
|
145
|
+
end
|
|
146
|
+
case response.status
|
|
147
|
+
when 401 then raise Unauthorized, "authentication failed"
|
|
148
|
+
when 403 then raise ProtocolError, "authentication request refused by service policy"
|
|
149
|
+
when 429 then raise RateLimited.new(retry_after: retry_after(response))
|
|
150
|
+
when 503 then raise Busy.new(retry_after: retry_after(response))
|
|
151
|
+
when 500..599 then raise Unavailable, "authentication service unavailable"
|
|
152
|
+
end
|
|
153
|
+
cache = response.headers.fetch("cache-control", []).join(",").split(",").map(&:strip)
|
|
154
|
+
unless response.status == status && cache.include?("no-store") &&
|
|
155
|
+
response.headers.fetch("content-encoding", ["identity"]) == ["identity"]
|
|
156
|
+
raise ProtocolError, "unexpected authentication response"
|
|
157
|
+
end
|
|
158
|
+
raise ProtocolError, "unexpected logout body" if status == 204 && !response.body.empty?
|
|
159
|
+
response
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def retry_after(response)
|
|
163
|
+
values = response.headers.fetch("retry-after", [])
|
|
164
|
+
return unless values.length == 1 && values[0].match?(/\A[0-9]{1,5}\z/)
|
|
165
|
+
value = Integer(values[0], 10)
|
|
166
|
+
value if (1..86_400).cover?(value)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def object(response, keys)
|
|
170
|
+
types = response.headers.fetch("content-type", [])
|
|
171
|
+
unless types.length == 1 && types[0].split(";").first.to_s.strip.casecmp?("application/json")
|
|
172
|
+
raise ProtocolError, "invalid authentication response"
|
|
173
|
+
end
|
|
174
|
+
result = JSON.parse(response.body)
|
|
175
|
+
unless result.is_a?(Hash) && result.keys.sort == keys.sort && result.values.all? { |value| value.is_a?(String) }
|
|
176
|
+
raise ProtocolError, "invalid authentication response"
|
|
177
|
+
end
|
|
178
|
+
result
|
|
179
|
+
rescue JSON::ParserError, EncodingError
|
|
180
|
+
raise ProtocolError, "invalid authentication response", cause: nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def metadata(response)
|
|
184
|
+
values = object(response, %w[session_id user_id org_id app_id expires_at])
|
|
185
|
+
unless %w[session_id user_id org_id app_id].all? { |key| canonical_uuid?(values[key]) } &&
|
|
186
|
+
values["org_id"] == org_id && values["app_id"] == app_id
|
|
187
|
+
raise ProtocolError, "unexpected session audience"
|
|
188
|
+
end
|
|
189
|
+
expiry = timestamp(values.fetch("expires_at"))
|
|
190
|
+
raise Unauthorized, "session expired" unless expiry > Time.now
|
|
191
|
+
Session.new(session_id: values["session_id"].freeze, user_id: values["user_id"].freeze,
|
|
192
|
+
org_id: org_id, app_id: app_id, expires_at: expiry)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def issued_token(response)
|
|
196
|
+
matches = response.headers.fetch("set-cookie", []).filter_map do |cookie|
|
|
197
|
+
pair = cookie.split(";", 2).first
|
|
198
|
+
name, value = pair.to_s.split("=", 2)
|
|
199
|
+
value if name == cookie_name
|
|
200
|
+
end
|
|
201
|
+
raise ProtocolError, "ambiguous session cookie" unless matches.length == 1
|
|
202
|
+
Token.new(matches[0])
|
|
203
|
+
rescue Unauthorized
|
|
204
|
+
raise ProtocolError, "invalid issued credential", cause: nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|