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 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