strongroom-rails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d266097d730b4561dc0e197cbfb8cb6811626b1cfea5defd21b7f018f0e39ed0
4
- data.tar.gz: efc7b6166160438836119409e1c836179daa40b33913dbe4f6a9464eff9f53d3
3
+ metadata.gz: ec4204432b51f2a1d28d6ca08fa846a6ed9413de15e2164dbc4f506aa0e3f474
4
+ data.tar.gz: 87eb737c063fbdbe0f8ba0e5767541c37abfa1addcf1e02117fa5754f51102ad
5
5
  SHA512:
6
- metadata.gz: d9200dd2cdf23a5822286500ee92e35c254d824a2d910c8039df5bfdab0f05402a51ed2dc6e601a7069db72584353d0e8a05681d2ff4b223b4040cd15f8ab6fe
7
- data.tar.gz: c109f7251482e29b24b2349ff5cce04958207eaca352d90d5a7b0311d38ca0abc7e55a763e5ec7f25309b323ee092ae8b3e6b415ac675322803e12da3cc5727b
6
+ metadata.gz: 9dd07c5ee1df0e5ec948bd724ea445f45d61d9c34ec0421ab9f838ebbaa3c41d66d0a997c39e6120d6671fb1f35dbe8c14a3b9c8bc46fc42a20e3ae01a92ff3d
7
+ data.tar.gz: a4bf94a507478ceeb8f83d5be2e1d3da89afe287e127bfc234d23535338236392db768159dcc80218ff3311c8245f7c5dab908bbdfdd9521ed6bf04b78ff5e9e
data/CHANGELOG.md CHANGED
@@ -1 +1,10 @@
1
1
  # Changelog
2
+
3
+ ## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * add self-service registration, email verification links, an unverified-login grace policy and an event feed ([b39e976](https://github.com/seuros/strongroom/commit/b39e976600085752350b333a3c82a00e4a0493bf))
9
+
10
+ ## Changelog
data/README.md CHANGED
@@ -1,8 +1,9 @@
1
- # strongroom-rails — existing-user login adapter
1
+ # strongroom-rails — login and registration adapter
2
2
 
3
3
  Rails adapter for [Strongroom](https://github.com/seuros/strongroom), a Postgres-first authentication kernel
4
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
5
+ hashes or session secrets. Registration goes through the kernel's service API
6
+ from your server; MFA, password recovery, JWT minting, mail and automatic routes
6
7
  are deliberately absent.
7
8
 
8
9
  > **Status: pre-production.** Strongroom has not been audited and is not approved
@@ -30,6 +31,9 @@ Requires Ruby **4.0+** and Rails **8.1+** (currently Rails 8.x).
30
31
  - [x] Request-level resolver and an optional Warden strategy.
31
32
  - [x] Trusted subject resolution into an allowlisted local model/table, followed by
32
33
  an application-supplied active/confirmed/MFA/cohort eligibility check.
34
+ - [x] Registration, verification links and the account event feed through the
35
+ kernel's service listener, with the namespace's service key (see
36
+ [Registration](#registration)). Sessions report `identity_verified`.
33
37
 
34
38
  It never finds users by email, creates local accounts, dynamically constantizes
35
39
  table names, overrides `current_user`, installs routes or falls back to legacy
@@ -99,7 +103,9 @@ authenticate (for example an asset build without credentials) can opt out with
99
103
  Add additional `Account` objects to the registry for other approved namespaces.
100
104
  At most 16 are supported; names, tables and same-issuer audiences must be distinct.
101
105
  Pass actual model classes, never class names from parameters/configuration.
102
- `register` defaults false; true records future policy only and adds no route.
106
+ `register` defaults false; `register: true` needs a client configured with
107
+ `service_endpoint:` and `service_key:` and enables `register_account` and
108
+ `issue_verification` on the account. The gem still installs no route.
103
109
 
104
110
  Controllers opt in explicitly and keep their own frontend response contract:
105
111
 
@@ -281,6 +287,98 @@ sent on a connection the server then dropped raises `Unavailable`. Pools are
281
287
  per process; a forked child never touches its parent's sockets. Call
282
288
  `transport.close` to drop idle connections at shutdown.
283
289
 
290
+ ## Registration
291
+
292
+ The kernel serves registration on its service listener (`server.service_listen`,
293
+ loopback or a private network), authenticated by the namespace's service key.
294
+ Give the client both; the key never appears in `inspect`, `pp` or YAML:
295
+
296
+ ```ruby
297
+ client = Strongroom::Client.new(
298
+ endpoint: ENV.fetch("STRONGROOM_ENDPOINT"), issuer: "https://auth.example.com", namespace: "users",
299
+ org_id: ENV.fetch("STRONGROOM_USERS_ORG_ID"), app_id: ENV.fetch("STRONGROOM_USERS_APP_ID"),
300
+ service_endpoint: ENV.fetch("STRONGROOM_SERVICE_ENDPOINT"), service_key: ENV.fetch("STRONGROOM_USERS_SERVICE_KEY")
301
+ )
302
+ users = Strongroom::Rails::Account.new(client: client, table: "users", model: User, register: true,
303
+ resolve: ..., eligible: ...)
304
+
305
+ registration = users.register_account(external_id: user.id.to_s, identity: params[:email],
306
+ password: params[:password])
307
+ registration.created # false for a replay of the same external_id and address
308
+ VerificationMailer.with(user: user).confirm.deliver_later # the user, never the token
309
+ ```
310
+
311
+ Issue the link where the mail is built, so the token never becomes a job
312
+ argument (ActiveJob stores and logs those):
313
+
314
+ ```ruby
315
+ class VerificationMailer < ApplicationMailer
316
+ def confirm
317
+ user = params.fetch(:user)
318
+ link = Rails.configuration.x.strongroom_accounts.fetch(:users)
319
+ .issue_verification(user_id: user.strongroom_user_id)
320
+ # The token goes in the fragment of a link to your own page: browsers never
321
+ # send a fragment, so neither your logs nor a Referer header see it.
322
+ @url = "https://app.example.com/email/confirm##{link.token.reveal}"
323
+ mail(to: user.email, subject: "Confirm your address")
324
+ end
325
+ end
326
+ ```
327
+
328
+ If the token must travel through a job, pass it only in an argument your own
329
+ `ActiveJob::Serializers::ObjectSerializer` encrypts (with
330
+ `ActiveSupport::MessageEncryptor`), or call `deliver_now` from the request.
331
+ Never pass the URL or `link.token.reveal` to `deliver_later`.
332
+
333
+ The page at `/email/confirm` shows a button; its click posts the fragment to
334
+ the kernel's public listener (the gem does not proxy it). List the page's origin
335
+ in the kernel's `[browser] allowed_origins`:
336
+
337
+ ```js
338
+ await fetch(`${STRONGROOM_ORIGIN}/accounts/users/verify`, {
339
+ method: "POST",
340
+ headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
341
+ body: JSON.stringify({ token: location.hash.slice(1) }),
342
+ }); // 200 {"status": "verified" | "already_verified"}, 400 {"error": "invalid_token", "reason": ...}
343
+ ```
344
+
345
+ A mail scanner that fetches the link only fetches your page: nothing it sends
346
+ verifies the address.
347
+
348
+ - `register` (on the client; `register_account` on an account) creates the
349
+ account with an unverified email identity. Replaying the same `external_id`
350
+ and address returns the original receipt and changes nothing, before any
351
+ password check. It raises `Strongroom::BindingConflict` (the external ID is
352
+ bound to another address), `Strongroom::IdentityTaken` (another account holds
353
+ the address) or `Strongroom::InvalidPassword` with `reason` `too_short`,
354
+ `too_long` or `invalid`. `IdentityTaken` tells your server an address has an
355
+ account: do not tell the person signing up.
356
+ - `cancel_registration(external_id:)` deletes a registration whose address was
357
+ never verified (a squatted address, a stale sign-up); the address and the
358
+ external ID are free again. It raises `Strongroom::AlreadyVerified`,
359
+ `Strongroom::IdentityDisabled`, `Strongroom::NotFound`, or `Strongroom::Busy`
360
+ while one of its sessions is mid-rotation (retry).
361
+ - `issue_verification(user_id:)` returns a `Strongroom::Verification` whose
362
+ `token` is redacted everywhere and is never a session credential. Earlier
363
+ links stop working. It raises `Strongroom::AlreadyVerified` or
364
+ `Strongroom::NotFound`.
365
+ - `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
366
+ `Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
367
+ `VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
368
+ `PasswordReplace`, `Revoke`); keep `page.next` and pass it as `after:`.
369
+ `next` is `nil` for an empty page: ask again later with the cursor you have.
370
+ `page.oldest_at` is the oldest event the kernel still holds; if your last
371
+ event is older, retention pruned events you never read. `Strongroom::CursorAhead`
372
+ means the kernel's database is behind your cursor (restored or failed over):
373
+ drop the cursor and read from the start.
374
+ - `Strongroom::Session#identity_verified` is `false` only for an unverified
375
+ identity inside the namespace's `unverified_grace_hours`; with the default of
376
+ `0` an unverified identity cannot log in at all.
377
+
378
+ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
379
+ a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
380
+ Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
381
+
284
382
  ## Running the daemon
285
383
 
286
384
  This gem is only the client: it ships no binary and needs none to install. The
@@ -310,9 +408,11 @@ The two gems are versioned separately.
310
408
  Local eligibility checks do not synchronize kernel account state or revoke
311
409
  all remote sessions. Password changes/resets, disablement and MFA enablement
312
410
  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.
411
+ - Imports remain an existing, confirmed, password-only cohort; registration
412
+ creates unverified accounts the kernel refuses until they are verified (or
413
+ until a configured grace runs out). Real admins and MFA-required accounts
414
+ remain on their explicitly selected legacy owner. Do not use failure fallback
415
+ or mislabel MFA state.
316
416
  - Define old-cookie/JWT retirement and canonical-session-bound compatibility
317
417
  before cutting an existing application over. This gem does not preserve existing JWT/frontend
318
418
  contracts automatically or share Rails signing secrets with Strongroom.
@@ -322,7 +422,7 @@ The two gems are versioned separately.
322
422
  Ruby/parser/network/framework copies cannot be reliably erased. Do not log
323
423
  headers, cookie jars, passwords, transport bodies or explicit token strings.
324
424
  The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
325
- - Registration/mail/recovery, bulk mapping orchestration, session families,
425
+ - Mail delivery, password recovery and change, bulk mapping orchestration,
326
426
  remote admin operations, deployment SLOs and production approval are not shipped.
327
427
 
328
428
  ## Local checks (no installs, network or database required)
@@ -405,8 +505,15 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
405
505
  - Exercises the Rails helpers through in-memory Rack/HTTPS requests, encrypted
406
506
  cookies, CSRF and a full-scope trusted mapping into synthetic local records.
407
507
  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.
508
+ - Registers an account through the service listener with a key minted for the
509
+ run, proves replay idempotency and the typed refusals, that the account
510
+ cannot log in before its link is confirmed and that a `GET` of the link does
511
+ nothing, then confirms it by `POST` on the public listener and logs in with
512
+ `identity_verified` true, reads the event feed, and cancels a squatted
513
+ registration so the address can be registered again.
514
+ - Checks the database for two bcrypt upgrades, one verified registration, two
515
+ rotations and no live sessions, and the daemon's `/admin/metrics` for the
516
+ exact login, registration and verification outcomes.
410
517
  - Bounds subprocesses and kills/reaps its owned process groups, drops its schema
411
518
  and removes temporary files on completion or handled failure/interruption.
412
519
  The caller remains responsible for stopping the supplied PostgreSQL server.
@@ -8,9 +8,18 @@ module Strongroom
8
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
9
  MICROSECOND = Rational(1, 1_000_000)
10
10
  ISSUER = /\A[a-z][a-z0-9+.-]*:[!-~]{1,255}\z/
11
- attr_reader :endpoint, :issuer, :namespace, :org_id, :app_id, :cookie_name
11
+ CURSOR = /\A[A-Za-z0-9_-]{34}\z/
12
+ IDENTITY_STATES = %w[Unverified Verified Disabled].freeze
13
+ EVENT_TYPES = %w[Provision Register RegistrationCancel VerificationIssue IdentityVerify PasswordHashUpgrade
14
+ PasswordReplace Revoke].freeze
15
+ LOGIN = {"session_id" => :uuid, "user_id" => :uuid, "expires_at" => :timestamp,
16
+ "identity_verified" => :boolean}.freeze
17
+ METADATA = LOGIN.merge("org_id" => :uuid, "app_id" => :uuid).freeze
18
+ SERVICE_KEY_HEADER = "X-Strongroom-Service-Key"
19
+ attr_reader :endpoint, :issuer, :namespace, :org_id, :app_id, :cookie_name, :service_endpoint
12
20
 
13
- def initialize(endpoint:, issuer:, namespace:, org_id:, app_id:, cookie_base: "__Host-strongroom_session", transport: nil)
21
+ def initialize(endpoint:, issuer:, namespace:, org_id:, app_id:, cookie_base: "__Host-strongroom_session",
22
+ transport: nil, service_endpoint: nil, service_key: nil, service_transport: nil)
14
23
  @endpoint = NetHTTPTransport.endpoint(endpoint)
15
24
  unless issuer.is_a?(String) && issuer.match?(ISSUER)
16
25
  raise ConfigurationError, "explicit issuer URI required (stable binding identity, not the network endpoint)"
@@ -24,13 +33,30 @@ module Strongroom
24
33
  @org_id, @app_id = [org_id, app_id].map { |value| configured_uuid(value) }
25
34
  @cookie_name = "#{cookie_base}_#{@namespace}".freeze
26
35
  @transport = transport || NetHTTPTransport.new(endpoint: @endpoint)
27
- unless serves_endpoint?(@transport)
36
+ unless serves_endpoint?(@transport, @endpoint)
28
37
  raise ConfigurationError, "transport endpoint does not match the client endpoint"
29
38
  end
39
+ configure_service(service_endpoint, service_key, service_transport)
30
40
  freeze
31
41
  end
32
42
 
33
- def inspect = "#<Strongroom::Client issuer=#{issuer} endpoint=#{endpoint} namespace=#{namespace}>"
43
+ def inspect
44
+ service = service? ? " service_endpoint=#{service_endpoint}" : ""
45
+ "#<Strongroom::Client issuer=#{issuer} endpoint=#{endpoint}#{service} namespace=#{namespace}>"
46
+ end
47
+
48
+ def pretty_print(printer) = printer.text(inspect)
49
+
50
+ def encode_with(coder)
51
+ coder.map = {"issuer" => issuer, "endpoint" => endpoint, "namespace" => namespace}
52
+ end
53
+
54
+ def marshal_dump
55
+ raise TypeError, "clients must not be serialized"
56
+ end
57
+
58
+ # Whether this client can reach the namespace's service API.
59
+ def service? = !@service_key.nil?
34
60
 
35
61
  def login(identity:, password:, identity_kind: "email")
36
62
  unless [identity, password, identity_kind].all? { |value| value.is_a?(String) }
@@ -40,7 +66,7 @@ module Strongroom
40
66
  raise InvalidRequest, "login request too large" if body.bytesize > 16_384
41
67
  response = request(:post, "auth/login", body: body)
42
68
  issue(response) do |token|
43
- declared = object(response, %w[session_id user_id expires_at])
69
+ declared = object(response, LOGIN)
44
70
  current = session(token: token)
45
71
  raise ProtocolError, "inconsistent authentication response" unless same_login?(declared, current)
46
72
  current
@@ -83,8 +109,81 @@ module Strongroom
83
109
  Subject.new(issuer: issuer, namespace: namespace, org_id: org_id, app_id: app_id, user_id: session.user_id)
84
110
  end
85
111
 
112
+ # Creates an account with an unverified email identity, keyed by your own
113
+ # `external_id`. Replaying the same external ID and identity returns the
114
+ # original receipt (`created: false`) and changes nothing.
115
+ def register(external_id:, identity:, password:)
116
+ unless [external_id, identity, password].all? { |value| value.is_a?(String) }
117
+ raise InvalidRequest, "registration fields must be strings"
118
+ end
119
+ body = JSON.generate(external_id: external_id, identity_kind: "email", identity: identity, password: password)
120
+ raise InvalidRequest, "registration request too large" if body.bytesize > 16_384
121
+ response = service_request(:post, "registrations", body: body, statuses: [200, 201])
122
+ values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "identity_state" => :string})
123
+ raise ProtocolError, "invalid registration response" unless IDENTITY_STATES.include?(values["identity_state"])
124
+ Registration.new(user_id: values["user_id"], identity_id: values["identity_id"],
125
+ identity_state: values["identity_state"], created: response.status == 201)
126
+ rescue JSON::GeneratorError
127
+ raise InvalidRequest, "invalid registration payload", cause: nil
128
+ end
129
+
130
+ # Withdraws a registration whose address was never verified: the account
131
+ # it created is deleted, and the address and external ID are free again.
132
+ def cancel_registration(external_id:)
133
+ raise InvalidRequest, "external_id must be a string" unless external_id.is_a?(String)
134
+ body = JSON.generate(external_id: external_id)
135
+ response = service_request(:post, "registrations/cancel", body: body, statuses: [200])
136
+ unless object(response, {"status" => :string})["status"] == "cancelled"
137
+ raise ProtocolError, "invalid service response"
138
+ end
139
+ nil
140
+ rescue JSON::GeneratorError
141
+ raise InvalidRequest, "invalid external_id", cause: nil
142
+ end
143
+
144
+ # A fresh verification link for `user_id`'s email identity; earlier links
145
+ # stop working. The browser confirms it: POST {"token"} to
146
+ # `/accounts/<namespace>/verify` on the kernel's public listener.
147
+ def issue_verification(user_id:)
148
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
149
+ response = service_request(:post, "verifications", body: JSON.generate(user_id: user_id), statuses: [201])
150
+ values = object(response, {"token" => :string, "expires_at" => :timestamp})
151
+ Verification.new(token: VerificationToken.new(values["token"]), expires_at: values["expires_at"])
152
+ end
153
+
154
+ # One page of the namespace's event feed, in commit-safe order.
155
+ def events(after: nil, limit: 100)
156
+ unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
157
+ limit.is_a?(Integer) && (1..500).cover?(limit)
158
+ raise InvalidRequest, "after: must be a feed cursor and limit: 1 to 500"
159
+ end
160
+ query = after ? "after=#{after}&limit=#{limit}" : "limit=#{limit}"
161
+ response = service_request(:get, "events?#{query}", statuses: [200])
162
+ page = parse(response)
163
+ unless page.is_a?(Hash) && page.keys.sort == %w[events next oldest_at] && page["events"].is_a?(Array) &&
164
+ (page["next"].nil? || (page["next"].is_a?(String) && page["next"].match?(CURSOR)))
165
+ raise ProtocolError, "invalid event feed"
166
+ end
167
+ EventPage.new(events: page["events"].map { |event| feed_event(event) }.freeze, next: page["next"]&.freeze,
168
+ oldest_at: page["oldest_at"]&.then { timestamp(it) })
169
+ end
170
+
86
171
  private
87
172
 
173
+ def configure_service(endpoint, key, transport)
174
+ if endpoint.nil? && key.nil? && transport.nil?
175
+ @service_endpoint = @service_key = @service_transport = nil
176
+ return
177
+ end
178
+ raise ConfigurationError, "service_endpoint: and service_key: go together" if endpoint.nil? || key.nil?
179
+ @service_endpoint = NetHTTPTransport.endpoint(endpoint)
180
+ @service_key = ServiceKey.new(key)
181
+ @service_transport = transport || NetHTTPTransport.new(endpoint: @service_endpoint)
182
+ unless serves_endpoint?(@service_transport, @service_endpoint)
183
+ raise ConfigurationError, "service transport endpoint does not match the service endpoint"
184
+ end
185
+ end
186
+
88
187
  def issue(response)
89
188
  token = issued_token(response)
90
189
  IssuedSession.new(session: yield(token), token: token)
@@ -95,7 +194,8 @@ module Strongroom
95
194
 
96
195
  def same_login?(declared, current)
97
196
  declared["session_id"] == current.session_id && declared["user_id"] == current.user_id &&
98
- same_instant?(timestamp(declared["expires_at"]), current.expires_at)
197
+ declared["identity_verified"] == current.identity_verified &&
198
+ same_instant?(declared["expires_at"], current.expires_at)
99
199
  end
100
200
 
101
201
  def same_session?(declared, current)
@@ -116,7 +216,7 @@ module Strongroom
116
216
  raise ProtocolError, "invalid authentication response", cause: nil
117
217
  end
118
218
 
119
- def serves_endpoint?(transport)
219
+ def serves_endpoint?(transport, endpoint)
120
220
  transport.respond_to?(:call) && transport.respond_to?(:endpoint) &&
121
221
  NetHTTPTransport.endpoint(transport.endpoint) == endpoint
122
222
  rescue ConfigurationError
@@ -140,9 +240,7 @@ module Strongroom
140
240
  headers["Content-Type"] = "application/json" if body
141
241
  headers["Authorization"] = "Bearer #{token.reveal}" if token
142
242
  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
243
+ bounded!(response)
146
244
  case response.status
147
245
  when 401 then raise Unauthorized, "authentication failed"
148
246
  when 403 then raise ProtocolError, "authentication request refused by service policy"
@@ -150,13 +248,71 @@ module Strongroom
150
248
  when 503 then raise Busy.new(retry_after: retry_after(response))
151
249
  when 500..599 then raise Unavailable, "authentication service unavailable"
152
250
  end
251
+ expected!(response, [status])
252
+ raise ProtocolError, "unexpected logout body" if status == 204 && !response.body.empty?
253
+ response
254
+ end
255
+
256
+ # The namespace's service API: a server-to-server call with the service
257
+ # key, never a browser's. Kernel refusals become typed errors.
258
+ def service_request(method, suffix, statuses:, body: nil)
259
+ raise ConfigurationError, "no service_endpoint: or service_key: configured for #{namespace}" unless service?
260
+ headers = {"Accept" => "application/json", "Accept-Encoding" => "identity",
261
+ "Cache-Control" => "no-store", SERVICE_KEY_HEADER => @service_key.reveal}
262
+ headers["Content-Type"] = "application/json" if body
263
+ response = @service_transport.call(method, "/accounts/#{namespace}/service/#{suffix}", headers: headers, body: body)
264
+ bounded!(response)
265
+ refused!(response) unless statuses.include?(response.status)
266
+ expected!(response, statuses)
267
+ response
268
+ end
269
+
270
+ def refused!(response)
271
+ case response.status
272
+ when 400 then raise InvalidRequest, "service request refused"
273
+ when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
274
+ when 404 then raise NotFound, "not found in this namespace"
275
+ when 409 then conflict!(response)
276
+ when 422
277
+ reason = service_error(response)&.fetch("reason", nil)
278
+ raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
279
+ raise ProtocolError, "invalid service response"
280
+ when 429 then raise RateLimited.new(retry_after: retry_after(response))
281
+ when 503 then raise Busy.new(retry_after: retry_after(response))
282
+ when 500..599 then raise Unavailable, "authentication service unavailable"
283
+ end
284
+ end
285
+
286
+ def conflict!(response)
287
+ case service_error(response)&.fetch("error", nil)
288
+ when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
289
+ when "binding_conflict" then raise BindingConflict, "external_id is bound to a different identity"
290
+ when "already_verified" then raise AlreadyVerified, "identity is already verified"
291
+ when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
292
+ when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
293
+ else raise ProtocolError, "invalid service response"
294
+ end
295
+ end
296
+
297
+ def service_error(response)
298
+ error = parse(response)
299
+ error if error.is_a?(Hash) && error.values.all?(String)
300
+ rescue ProtocolError
301
+ nil
302
+ end
303
+
304
+ def bounded!(response)
305
+ unless response.is_a?(TransportResponse) && response.body.bytesize <= NetHTTPTransport::MAX_RESPONSE_BYTES
306
+ raise ProtocolError, "invalid authentication response"
307
+ end
308
+ end
309
+
310
+ def expected!(response, statuses)
153
311
  cache = response.headers.fetch("cache-control", []).join(",").split(",").map(&:strip)
154
- unless response.status == status && cache.include?("no-store") &&
312
+ unless statuses.include?(response.status) && cache.include?("no-store") &&
155
313
  response.headers.fetch("content-encoding", ["identity"]) == ["identity"]
156
314
  raise ProtocolError, "unexpected authentication response"
157
315
  end
158
- raise ProtocolError, "unexpected logout body" if status == 204 && !response.body.empty?
159
- response
160
316
  end
161
317
 
162
318
  def retry_after(response)
@@ -166,30 +322,65 @@ module Strongroom
166
322
  value if (1..86_400).cover?(value)
167
323
  end
168
324
 
169
- def object(response, keys)
325
+ def parse(response)
170
326
  types = response.headers.fetch("content-type", [])
171
327
  unless types.length == 1 && types[0].split(";").first.to_s.strip.casecmp?("application/json")
172
328
  raise ProtocolError, "invalid authentication response"
173
329
  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
330
+ JSON.parse(response.body)
179
331
  rescue JSON::ParserError, EncodingError
180
332
  raise ProtocolError, "invalid authentication response", cause: nil
181
333
  end
182
334
 
335
+ # Exactly `schema`'s keys, each of its kind; timestamps parsed.
336
+ def object(response, schema)
337
+ result = parse(response)
338
+ unless result.is_a?(Hash) && result.keys.sort == schema.keys.sort &&
339
+ schema.all? { |key, kind| kind?(result[key], kind) }
340
+ raise ProtocolError, "invalid authentication response"
341
+ end
342
+ result.to_h { |key, value| [key, schema[key] == :timestamp ? timestamp(value) : value] }
343
+ end
344
+
345
+ def kind?(value, kind)
346
+ case kind
347
+ when :uuid then canonical_uuid?(value)
348
+ when :boolean then value == true || value == false
349
+ when :uuid_or_nil then value.nil? || canonical_uuid?(value)
350
+ else value.is_a?(String)
351
+ end
352
+ end
353
+
183
354
  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
355
+ values = object(response, METADATA)
356
+ unless values["org_id"] == org_id && values["app_id"] == app_id
187
357
  raise ProtocolError, "unexpected session audience"
188
358
  end
189
- expiry = timestamp(values.fetch("expires_at"))
359
+ expiry = values.fetch("expires_at")
190
360
  raise Unauthorized, "session expired" unless expiry > Time.now
191
361
  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)
362
+ org_id: org_id, app_id: app_id, expires_at: expiry, identity_verified: values["identity_verified"])
363
+ end
364
+
365
+ EVENT = {"id" => :uuid, "type" => :string, "user_id" => :uuid_or_nil, "identity_id" => :uuid_or_nil,
366
+ "at" => :timestamp, "meta" => :meta}.freeze
367
+ private_constant :EVENT
368
+
369
+ def feed_event(event)
370
+ unless event.is_a?(Hash) && event.keys.sort == EVENT.keys.sort && EVENT_TYPES.include?(event["type"]) &&
371
+ event["meta"].is_a?(Hash) && EVENT.all? { |key, kind| kind == :meta || kind?(event[key], kind) }
372
+ raise ProtocolError, "invalid event feed"
373
+ end
374
+ Event.new(id: event["id"].freeze, type: event["type"].freeze, user_id: event["user_id"]&.freeze,
375
+ identity_id: event["identity_id"]&.freeze, at: timestamp(event["at"]), meta: deep_freeze(event["meta"]))
376
+ end
377
+
378
+ def deep_freeze(value)
379
+ case value
380
+ when Hash then value.to_h { |key, item| [key.freeze, deep_freeze(item)] }.freeze
381
+ when Array then value.map { |item| deep_freeze(item) }.freeze
382
+ else value.freeze
383
+ end
193
384
  end
194
385
 
195
386
  def issued_token(response)
@@ -7,6 +7,30 @@ module Strongroom
7
7
  class Unauthorized < Error; end
8
8
  class ProtocolError < Error; end
9
9
  class Unavailable < Error; end
10
+ class NotFound < Error; end
11
+
12
+ # The service API refused a request that conflicts with stored state.
13
+ class Conflict < Error; end
14
+ class IdentityTaken < Conflict; end
15
+ class BindingConflict < Conflict; end
16
+ class AlreadyVerified < Conflict; end
17
+ class IdentityDisabled < Conflict; end
18
+
19
+ # The feed cursor names a point the kernel's database has not reached (it was
20
+ # restored, failed over or copied): drop the cursor and read from the start.
21
+ class CursorAhead < Conflict; end
22
+
23
+ # A new password the kernel's policy refuses; `reason` is `too_short`,
24
+ # `too_long` or `invalid`. The password itself is never kept.
25
+ class InvalidPassword < InvalidRequest
26
+ REASONS = %w[too_short too_long invalid].freeze
27
+ attr_reader :reason
28
+
29
+ def initialize(reason:)
30
+ @reason = reason
31
+ super("password refused: #{reason}")
32
+ end
33
+ end
10
34
 
11
35
  class RateLimited < Error
12
36
  attr_reader :retry_after
@@ -20,12 +20,39 @@ module Strongroom
20
20
  raise ConfigurationError, "resolve must be callable (subject -> record or nil)" unless resolve.respond_to?(:call)
21
21
  raise ConfigurationError, "eligible must be callable (record -> true)" unless eligible.respond_to?(:call)
22
22
  raise ConfigurationError, "register must be true or false" unless [true, false].include?(register)
23
+ if register && !client.service?
24
+ raise ConfigurationError, "register: true needs a client with service_endpoint: and service_key:"
25
+ end
23
26
  @client, @model, @table = client, model, table.dup.freeze
24
27
  @resolver, @eligible, @register = resolve, eligible, register
25
28
  @cookie_name = "strongroom_#{client.namespace}".freeze
26
29
  freeze
27
30
  end
28
31
 
32
+ # Self-service registration through the kernel's service API. Only a
33
+ # namespace configured with `register: true` (here and in the kernel) may.
34
+ def register_account(external_id:, identity:, password:)
35
+ registering!
36
+ client.register(external_id: external_id, identity: identity, password: password)
37
+ end
38
+
39
+ # Withdraws a registration whose address was never verified.
40
+ def cancel_registration(external_id:)
41
+ registering!
42
+ client.cancel_registration(external_id: external_id)
43
+ end
44
+
45
+ # A fresh verification link for a registered user's email identity.
46
+ def issue_verification(user_id:)
47
+ registering!
48
+ client.issue_verification(user_id: user_id)
49
+ end
50
+
51
+ # The namespace's event feed; needs only the service key.
52
+ def events(after: nil, limit: 100)
53
+ client.events(after: after, limit: limit)
54
+ end
55
+
29
56
  def resolve(session)
30
57
  subject = client.subject(session)
31
58
  record = @resolver.call(subject)
@@ -38,6 +65,12 @@ module Strongroom
38
65
  rescue StandardError
39
66
  raise Unavailable, "local principal lookup unavailable", cause: nil
40
67
  end
68
+
69
+ private
70
+
71
+ def registering!
72
+ raise ConfigurationError, "registration is not enabled for #{client.namespace} (register: false)" unless register
73
+ end
41
74
  end
42
75
  end
43
76
  end
@@ -8,7 +8,15 @@ module Strongroom
8
8
 
9
9
  config.action_dispatch.rescue_responses.merge!(
10
10
  "Strongroom::InvalidRequest" => :bad_request,
11
+ "Strongroom::InvalidPassword" => :unprocessable_entity,
11
12
  "Strongroom::Unauthorized" => :unauthorized,
13
+ "Strongroom::NotFound" => :not_found,
14
+ "Strongroom::Conflict" => :conflict,
15
+ "Strongroom::IdentityTaken" => :conflict,
16
+ "Strongroom::BindingConflict" => :conflict,
17
+ "Strongroom::AlreadyVerified" => :conflict,
18
+ "Strongroom::IdentityDisabled" => :conflict,
19
+ "Strongroom::CursorAhead" => :conflict,
12
20
  "Strongroom::RateLimited" => :too_many_requests,
13
21
  "Strongroom::Busy" => :service_unavailable,
14
22
  "Strongroom::ProtocolError" => :service_unavailable,
@@ -16,7 +24,8 @@ module Strongroom
16
24
  )
17
25
 
18
26
  initializer "strongroom.filter_credentials" do |app|
19
- app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token]
27
+ app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token
28
+ service_key verification_token]
20
29
  end
21
30
 
22
31
  config.after_initialize do |app|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strongroom
4
4
  module Rails
5
- VERSION = "0.1.0" # x-release-please-version
5
+ VERSION = "0.2.0" # x-release-please-version
6
6
  end
7
7
  end
@@ -3,9 +3,24 @@
3
3
  require "time"
4
4
 
5
5
  module Strongroom
6
- Session = Data.define(:session_id, :user_id, :org_id, :app_id, :expires_at)
6
+ Session = Data.define(:session_id, :user_id, :org_id, :app_id, :expires_at, :identity_verified)
7
7
  Subject = Data.define(:issuer, :namespace, :org_id, :app_id, :user_id)
8
8
 
9
+ # A registration's receipt. `created` is false for an exact replay, which
10
+ # changed nothing; `identity_state` is the identity's current state.
11
+ Registration = Data.define(:user_id, :identity_id, :identity_state, :created)
12
+
13
+ # One account event from the namespace's feed. `meta` is a frozen Hash of
14
+ # non-secret IDs (session, credential, token).
15
+ Event = Data.define(:id, :type, :user_id, :identity_id, :at, :meta)
16
+
17
+ # A page of the feed. `next` is the cursor after its last event, or nil when
18
+ # the page is empty: keep the previous cursor and ask again later.
19
+ # `oldest_at` is when the oldest event the feed still holds happened (nil if
20
+ # none): retention prunes from there, so a consumer whose last event is older
21
+ # has lost the events in between.
22
+ EventPage = Data.define(:events, :next, :oldest_at)
23
+
9
24
  class IssuedSession
10
25
  attr_reader :session, :token
11
26
 
@@ -21,4 +36,28 @@ module Strongroom
21
36
  raise TypeError, "issued credentials must not be serialized"
22
37
  end
23
38
  end
39
+
40
+ # A verification link's secret. Put it in the URL fragment of a link to your
41
+ # own page, whose button posts it to the kernel; nothing here is safe to log
42
+ # or serialize.
43
+ class Verification
44
+ attr_reader :token, :expires_at
45
+
46
+ def initialize(token:, expires_at:)
47
+ @token, @expires_at = token, expires_at
48
+ freeze
49
+ end
50
+
51
+ def inspect = "#<Strongroom::Verification expires_at=#{expires_at.iso8601} token=[FILTERED]>"
52
+ alias to_s inspect
53
+ def pretty_print(printer) = printer.text(inspect)
54
+
55
+ def as_json(*)
56
+ raise TypeError, "verification links must not be serialized"
57
+ end
58
+
59
+ def marshal_dump
60
+ raise TypeError, "verification links must not be serialized"
61
+ end
62
+ end
24
63
  end
@@ -3,24 +3,29 @@
3
3
  require "base64"
4
4
 
5
5
  module Strongroom
6
- class Token
6
+ # A kernel secret: a fixed prefix and 32 random bytes in canonical unpadded
7
+ # base64url. Redacted everywhere; never JSON, YAML or Marshal payloads.
8
+ class Credential
9
+ def self.prefix = raise(NotImplementedError, "#{self} has no wire prefix")
10
+ def self.refusal = [Unauthorized, "invalid credential"]
11
+
7
12
  def initialize(value)
8
- unless value.is_a?(String) && value.bytesize == 47 && value.match?(/\Asr1_[A-Za-z0-9_-]{43}\z/)
9
- raise Unauthorized, "invalid session credential", cause: nil
13
+ prefix = self.class.prefix
14
+ unless value.is_a?(String) && value.bytesize == prefix.bytesize + 43 && value.start_with?(prefix) &&
15
+ value.byteslice(prefix.bytesize..).match?(/\A[A-Za-z0-9_-]{43}\z/)
16
+ refuse
10
17
  end
11
- encoded = value.delete_prefix("sr1_")
18
+ encoded = value.delete_prefix(prefix)
12
19
  decoded = Base64.urlsafe_decode64(encoded)
13
- unless decoded.bytesize == 32 && Base64.urlsafe_encode64(decoded, padding: false) == encoded
14
- raise Unauthorized, "invalid session credential", cause: nil
15
- end
20
+ refuse unless decoded.bytesize == 32 && Base64.urlsafe_encode64(decoded, padding: false) == encoded
16
21
  @wire = value.dup.freeze
17
22
  freeze
18
23
  rescue ArgumentError
19
- raise Unauthorized, "invalid session credential", cause: nil
24
+ refuse
20
25
  end
21
26
 
22
27
  def reveal = @wire
23
- def inspect = "#<Strongroom::Token [FILTERED]>"
28
+ def inspect = "#<#{self.class.name} [FILTERED]>"
24
29
  alias to_s inspect
25
30
  def as_json(*) = "[FILTERED]"
26
31
  def pretty_print(printer) = printer.text(inspect)
@@ -30,13 +35,26 @@ module Strongroom
30
35
  end
31
36
 
32
37
  def init_with(_coder)
33
- raise TypeError, "session tokens must not be deserialized"
38
+ raise TypeError, "credentials must not be deserialized"
34
39
  end
35
40
 
36
41
  def _dump(_depth)
37
- raise TypeError, "session tokens must not be serialized"
42
+ raise TypeError, "credentials must not be serialized"
38
43
  end
39
44
 
45
+ private
46
+
47
+ def refuse
48
+ error, message = self.class.refusal
49
+ raise error, message, cause: nil
50
+ end
51
+ end
52
+
53
+ # A session bearer token, `sr1_...`.
54
+ class Token < Credential
55
+ def self.prefix = "sr1_"
56
+ def self.refusal = [Unauthorized, "invalid session credential"]
57
+
40
58
  def self.coerce(value)
41
59
  value.is_a?(self) ? value : new(value)
42
60
  end
@@ -50,4 +68,22 @@ module Strongroom
50
68
  nil
51
69
  end
52
70
  end
71
+
72
+ # A verification link's secret, `srv1_...`: mail it, never log it.
73
+ class VerificationToken < Credential
74
+ def self.prefix = "srv1_"
75
+ def self.refusal = [ProtocolError, "invalid verification token"]
76
+ end
77
+
78
+ # A namespace's service key, `srk1_...`, sent only to the service listener.
79
+ class ServiceKey < Credential
80
+ def self.prefix = "srk1_"
81
+ def self.refusal = [ConfigurationError, "service_key: must be srk1_ followed by 43 base64url characters"]
82
+
83
+ def initialize(value)
84
+ super
85
+ # Every byte the same is a placeholder, as the kernel refuses too.
86
+ refuse if Base64.urlsafe_decode64(@wire.delete_prefix("srk1_")).bytes.uniq.size == 1
87
+ end
88
+ end
53
89
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strongroom-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seuros