strongroom-rails 0.2.0 → 0.4.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: ec4204432b51f2a1d28d6ca08fa846a6ed9413de15e2164dbc4f506aa0e3f474
4
- data.tar.gz: 87eb737c063fbdbe0f8ba0e5767541c37abfa1addcf1e02117fa5754f51102ad
3
+ metadata.gz: 5561c0010955953673794759017eb0f3683d838d60dd70ed09ae88f185954717
4
+ data.tar.gz: 0d620abade30695c4779dc8e123c9444b4ff723904e3bc19bd903985f1f47d53
5
5
  SHA512:
6
- metadata.gz: 9dd07c5ee1df0e5ec948bd724ea445f45d61d9c34ec0421ab9f838ebbaa3c41d66d0a997c39e6120d6671fb1f35dbe8c14a3b9c8bc46fc42a20e3ae01a92ff3d
7
- data.tar.gz: a4bf94a507478ceeb8f83d5be2e1d3da89afe287e127bfc234d23535338236392db768159dcc80218ff3311c8245f7c5dab908bbdfdd9521ed6bf04b78ff5e9e
6
+ metadata.gz: aa14aa3c3c14336f886876097bb85afb3c56332583567eadbf88b3839abbf518e8af4841a80023d0085ad5e7df6b58a9dff0962c875b0a028751dd4ff6c42046
7
+ data.tar.gz: 333bce6b18f73ec77edbe98645668cd40bcbcaa3443eff5e501706c4e915aee4c18c1808298022222622bf4708cf80f21dd4f6e0923aaa0fc03aded6e51dac61
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.3.0...strongroom-rails-v0.4.0) (2026-10-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * add account suspension and session revocation, service imports with conditional password replacement and in-daemon pruning; recover only verified identities ([be52bd1](https://github.com/seuros/strongroom/commit/be52bd1654b9f104aa8e82eb2129d0751a62f3d1))
9
+
10
+ ## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
11
+
12
+
13
+ ### Features
14
+
15
+ * add password recovery links and self-service password change ([b4f9160](https://github.com/seuros/strongroom/commit/b4f9160b352fb0122086c57a56c701ddb29b461a))
16
+
3
17
  ## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
4
18
 
5
19
 
data/README.md CHANGED
@@ -2,9 +2,9 @@
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 goes through the kernel's service API
6
- from your server; MFA, password recovery, JWT minting, mail and automatic routes
7
- are deliberately absent.
5
+ hashes or session secrets. Registration and recovery links go through the
6
+ kernel's service API from your server; MFA, JWT minting, mail and automatic
7
+ routes are deliberately absent.
8
8
 
9
9
  > **Status: pre-production.** Strongroom has not been audited and is not approved
10
10
  > for production traffic or real credential imports.
@@ -34,6 +34,8 @@ Requires Ruby **4.0+** and Rails **8.1+** (currently Rails 8.x).
34
34
  - [x] Registration, verification links and the account event feed through the
35
35
  kernel's service listener, with the namespace's service key (see
36
36
  [Registration](#registration)). Sessions report `identity_verified`.
37
+ - [x] Self-service password change and password recovery links (see
38
+ [Password change and recovery](#password-change-and-recovery)).
37
39
 
38
40
  It never finds users by email, creates local accounts, dynamically constantizes
39
41
  table names, overrides `current_user`, installs routes or falls back to legacy
@@ -105,7 +107,10 @@ At most 16 are supported; names, tables and same-issuer audiences must be distin
105
107
  Pass actual model classes, never class names from parameters/configuration.
106
108
  `register` defaults false; `register: true` needs a client configured with
107
109
  `service_endpoint:` and `service_key:` and enables `register_account` and
108
- `issue_verification` on the account. The gem still installs no route.
110
+ `issue_verification` on the account; `issue_recovery`, `events`, `suspend`,
111
+ `reinstate` and `revoke_sessions` need only the service key, and
112
+ `import_account` and `replace_imported_password` a namespace with
113
+ `import = true`. The gem still installs no route.
109
114
 
110
115
  Controllers opt in explicitly and keep their own frontend response contract:
111
116
 
@@ -365,7 +370,9 @@ verifies the address.
365
370
  - `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
366
371
  `Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
367
372
  `VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
368
- `PasswordReplace`, `Revoke`); keep `page.next` and pass it as `after:`.
373
+ `PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
374
+ `PasswordChange`, `AccountSuspend`, `AccountReinstate`); keep `page.next`
375
+ and pass it as `after:`.
369
376
  `next` is `nil` for an empty page: ask again later with the cursor you have.
370
377
  `page.oldest_at` is the oldest event the kernel still holds; if your last
371
378
  event is older, retention pruned events you never read. `Strongroom::CursorAhead`
@@ -379,6 +386,141 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
379
386
  a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
380
387
  Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
381
388
 
389
+ ## Account control and imports
390
+
391
+ On the client of any keyed namespace:
392
+
393
+ - `suspend(user_id:)` suspends the user's account in the namespace's org: every
394
+ session it holds there ends, its verification and recovery links stop
395
+ working, and nothing authenticates until `reinstate(user_id:)`, which leaves
396
+ those sessions and links ended. Both return a `Strongroom::AccountChange`
397
+ (`state`, `changed`: false for a repeat, `revoked_sessions`).
398
+ - `revoke_sessions(user_id:)` ends every live session of the user in the org
399
+ ("sign out everywhere") and returns how many ended.
400
+ - All three raise `Strongroom::NotFound` for a user outside the namespace,
401
+ `Strongroom::AccountDisabled` (suspension and reinstatement) for a disabled
402
+ account, and `Strongroom::Busy` while one of the user's sessions is being
403
+ rotated or logged out: retry, nothing changed.
404
+
405
+ With `import = true` on the namespace:
406
+
407
+ - `import_account(source:, external_id:, identity:, password_hash:,
408
+ account_state:, grant_state:, identity_state:, credential_state:,
409
+ mfa_required:)` imports an account with a pre-hashed password, as
410
+ `strongroom-admin provision` does, and returns a `Strongroom::Import`
411
+ (`user_id`, `identity_id`, `credential_id`, `created`: false for a replay).
412
+ Every state is explicit. It raises `Strongroom::BindingConflict`,
413
+ `Strongroom::IdentityTaken` or `Strongroom::InvalidRequest`.
414
+ - `replace_imported_password(source:, external_id:, expected_password_hash:,
415
+ password_hash:)` replaces that account's password only while it still holds
416
+ `expected_password_hash`, and returns a `Strongroom::PasswordReplacement`
417
+ (`status` `"replaced"` or `"unchanged"`). Once the kernel owns the password
418
+ (a login upgraded the hash, a recovery or password change replaced it), it
419
+ raises `Strongroom::CredentialChanged` and changes nothing: stop sending
420
+ hashes for that account.
421
+
422
+ ## Password change and recovery
423
+
424
+ ### Changing a password
425
+
426
+ A signed-in user changes their password through your controller; the helper
427
+ verifies mutation intent like the others, sends the request's session to the
428
+ kernel and replaces the encrypted cookie with the session the kernel rotated
429
+ onto the new password. The user stays signed in; every other session of theirs
430
+ in the org ends, in every app.
431
+
432
+ ```ruby
433
+ def update # POST; verified CSRF required.
434
+ user = strongroom_change_password(namespace: :users,
435
+ current_password: params.require(:current_password), new_password: params.require(:new_password))
436
+ render json: {id: user.id}
437
+ end
438
+ ```
439
+
440
+ A wrong current password raises `Strongroom::Unauthorized` (401) and changes
441
+ nothing: the session keeps working. A refused new password raises
442
+ `Strongroom::InvalidPassword` with `reason` `too_short`, `too_long` or
443
+ `invalid` (422), before the current password is even checked. An unverified
444
+ identity raises `Strongroom::IdentityUnverified` (409): nobody has proven they
445
+ own the address, so its password proves nothing; have the user confirm it
446
+ first. A session issued with a password the identity no longer logs in with
447
+ raises `Strongroom::CredentialChanged` (409): sign in again. The kernel's
448
+ per-user budget raises `Strongroom::RateLimited` (429). Add your own per-IP
449
+ `rate_limit` too, as for logins. Recovery links the user asked for keep
450
+ working after a change. `client.change_password(token:, current_password:,
451
+ new_password:)` (or `account.change_password`) is the same call without the
452
+ cookie: it returns the rotated `Strongroom::IssuedSession`, confirmed through
453
+ `/me` like a rotation. If that confirmation fails after the kernel made the
454
+ change, the rotated session is logged out before `ProtocolError` is raised:
455
+ the new password is set, and the user signs in with it.
456
+
457
+ ### Recovering a password
458
+
459
+ Any namespace whose client has `service_endpoint:` and `service_key:` can
460
+ issue recovery links, for imported and registered accounts alike; `register:`
461
+ is not needed. Ask for a link where the mail is built, never in the request
462
+ that asked for it, and never hand the token to a job:
463
+
464
+ ```ruby
465
+ class RecoveriesController < ApplicationController
466
+ rate_limit to: 5, within: 10.minutes, by: -> { request.remote_ip }, only: :create
467
+
468
+ def create # POST; the same answer whether or not the address has an account.
469
+ RecoveryMailer.with(email: params.require(:email)).reset.deliver_later # the address, never a token
470
+ head :accepted
471
+ end
472
+ end
473
+
474
+ class RecoveryMailer < ApplicationMailer
475
+ def reset
476
+ users = Rails.configuration.x.strongroom_accounts.fetch(:users)
477
+ link = users.issue_recovery(identity: params.fetch(:email))
478
+ # The fragment of a link to your own page: browsers never send it, so
479
+ # neither your logs nor a Referer header see the token.
480
+ @url = "https://app.example.com/password/reset##{link.token.reveal}"
481
+ mail(to: params.fetch(:email), subject: "Choose a new password")
482
+ rescue Strongroom::NotFound
483
+ # No usable account holds the address: without `mail`, nothing is sent.
484
+ rescue Strongroom::IdentityUnverified
485
+ # Registered, never verified: cancel that registration instead (see below).
486
+ end
487
+ end
488
+ ```
489
+
490
+ `issue_recovery(identity:)` returns a `Strongroom::Recovery` (`token`, a
491
+ `Strongroom::RecoveryToken` redacted everywhere and never a session credential;
492
+ `expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
493
+ address stop working. It raises `Strongroom::NotFound` when no usable account
494
+ holds the address in the namespace, and `Strongroom::IdentityUnverified` when
495
+ the address was registered but never verified: answer your users the same way
496
+ in every case, or the form tells anyone which addresses have accounts. If the token must
497
+ travel through a job after all, only in an argument your own
498
+ `ActiveJob::Serializers::ObjectSerializer` encrypts.
499
+
500
+ The page at `/password/reset` asks for the new password and posts it with the
501
+ fragment to the kernel's public listener (list the page's origin in its
502
+ `[browser] allowed_origins`; the gem does not proxy it):
503
+
504
+ ```js
505
+ const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
506
+ method: "POST",
507
+ headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
508
+ body: JSON.stringify({ token: location.hash.slice(1), password: form.password.value }),
509
+ }); // 200 {"status": "recovered"}; 422 {"error": "invalid_password", "reason": ...} keeps the link
510
+ // usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
511
+ ```
512
+
513
+ A completed recovery sets the new password and ends every session of the user
514
+ in the org (in every app) and their other recovery links. Send the person to
515
+ your sign-in page afterwards. A second use of the link answers
516
+ `invalid_token`. Links belong to the address across every app of the org.
517
+
518
+ Only verified addresses recover: anybody can register an address, and its
519
+ owner would inherit the account whoever registered it prepared (your records
520
+ for that `external_id`). On `Strongroom::IdentityUnverified`, cancel that
521
+ registration and let the owner register afresh; do not send a verification
522
+ link instead, which would let the owner verify that account and recover it.
523
+
382
524
  ## Running the daemon
383
525
 
384
526
  This gem is only the client: it ships no binary and needs none to install. The
@@ -406,8 +548,9 @@ The two gems are versioned separately.
406
548
 
407
549
  - Keep direct backend access restricted and TLS/service networking trusted.
408
550
  Local eligibility checks do not synchronize kernel account state or revoke
409
- all remote sessions. Password changes/resets, disablement and MFA enablement
410
- still need authoritative credential/state updates and invalidation.
551
+ all remote sessions. Disablement and MFA enablement still need authoritative
552
+ credential/state updates and invalidation; password changes and recoveries
553
+ through the kernel revoke its sessions themselves.
411
554
  - Imports remain an existing, confirmed, password-only cohort; registration
412
555
  creates unverified accounts the kernel refuses until they are verified (or
413
556
  until a configured grace runs out). Real admins and MFA-required accounts
@@ -422,8 +565,8 @@ The two gems are versioned separately.
422
565
  Ruby/parser/network/framework copies cannot be reliably erased. Do not log
423
566
  headers, cookie jars, passwords, transport bodies or explicit token strings.
424
567
  The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
425
- - Mail delivery, password recovery and change, bulk mapping orchestration,
426
- remote admin operations, deployment SLOs and production approval are not shipped.
568
+ - Mail delivery, bulk mapping orchestration, remote admin operations,
569
+ deployment SLOs and production approval are not shipped.
427
570
 
428
571
  ## Local checks (no installs, network or database required)
429
572
 
@@ -495,7 +638,8 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
495
638
  `__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
496
639
  daemon exits, the failure carries its stderr (database URL redacted).
497
640
  - Seeds only synthetic org/app bindings, then uses the actual admin CLI to
498
- provision two distinct, confirmed password-only principals with the same email.
641
+ provision three distinct, confirmed password-only principals, two of them with
642
+ the same email in different namespaces.
499
643
  The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
500
644
  - Installs a fresh package in a temporary consumer bundle, then uses its real
501
645
  Net::HTTP client for login, audience isolation, rotation, reuse detection,
@@ -511,9 +655,16 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
511
655
  nothing, then confirms it by `POST` on the public listener and logs in with
512
656
  `identity_verified` true, reads the event feed, and cancels a squatted
513
657
  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.
658
+ - Changes a registered account's password: a wrong current password and a weak
659
+ new one change nothing, the session that asked continues on the new
660
+ password, its other session and the old password stop working. Recovers a
661
+ registered account (refused weak password, one-time link, every session
662
+ ended, the new password logs in), an address that was never verified (the
663
+ recovery verifies it) and an imported bcrypt account.
664
+ - Checks the database for the imports, the verified registrations, the bcrypt
665
+ upgrades, the rotations and no live sessions, and the daemon's
666
+ `/admin/metrics` for the exact login, registration, verification, recovery
667
+ and password change outcomes.
517
668
  - Bounds subprocesses and kills/reaps its owned process groups, drops its schema
518
669
  and removes temporary files on completion or handled failure/interruption.
519
670
  The caller remains responsible for stopping the supplied PostgreSQL server.
@@ -11,7 +11,7 @@ module Strongroom
11
11
  CURSOR = /\A[A-Za-z0-9_-]{34}\z/
12
12
  IDENTITY_STATES = %w[Unverified Verified Disabled].freeze
13
13
  EVENT_TYPES = %w[Provision Register RegistrationCancel VerificationIssue IdentityVerify PasswordHashUpgrade
14
- PasswordReplace Revoke].freeze
14
+ PasswordReplace Revoke RecoveryIssue RecoveryComplete PasswordChange AccountSuspend AccountReinstate].freeze
15
15
  LOGIN = {"session_id" => :uuid, "user_id" => :uuid, "expires_at" => :timestamp,
16
16
  "identity_verified" => :boolean}.freeze
17
17
  METADATA = LOGIN.merge("org_id" => :uuid, "app_id" => :uuid).freeze
@@ -89,6 +89,32 @@ module Strongroom
89
89
  end
90
90
  end
91
91
 
92
+ # Changes the password of the identity `token`'s session was issued for and
93
+ # returns that session rotated onto it, confirmed through `/me` as rotation
94
+ # is. Every other session of the user in the org ends. A wrong
95
+ # `current_password` raises `Unauthorized` and leaves the session as it was;
96
+ # an unverified identity raises `IdentityUnverified`, a session issued with
97
+ # a password the identity no longer logs in with `CredentialChanged`. If
98
+ # the change went through but `/me` does not confirm the rotated session,
99
+ # that session is logged out before `ProtocolError` is raised: the new
100
+ # password is set and the user signs in with it.
101
+ def change_password(token:, current_password:, new_password:)
102
+ unless [current_password, new_password].all? { |value| value.is_a?(String) }
103
+ raise InvalidRequest, "passwords must be strings"
104
+ end
105
+ body = JSON.generate(current_password: current_password, new_password: new_password)
106
+ raise InvalidRequest, "password change request too large" if body.bytesize > 16_384
107
+ response = request(:post, "auth/password", token: Token.coerce(token), body: body)
108
+ issue(response) do |successor|
109
+ declared = metadata(response)
110
+ current = session(token: successor)
111
+ raise ProtocolError, "inconsistent password change response" unless same_session?(declared, current)
112
+ current
113
+ end
114
+ rescue JSON::GeneratorError
115
+ raise InvalidRequest, "invalid password change payload", cause: nil
116
+ end
117
+
92
118
  def logout(token:)
93
119
  request(:delete, "auth/logout", token: Token.coerce(token), status: 204)
94
120
  nil
@@ -151,6 +177,97 @@ module Strongroom
151
177
  Verification.new(token: VerificationToken.new(values["token"]), expires_at: values["expires_at"])
152
178
  end
153
179
 
180
+ # A recovery link for the account holding the email address `identity` in
181
+ # this namespace; earlier recovery links of that address stop working. The
182
+ # browser completes it: POST {"token", "password"} to
183
+ # `/accounts/<namespace>/recovery` on the kernel's public listener. Raises
184
+ # `NotFound` when no usable account holds the address, and
185
+ # `IdentityUnverified` when the address was registered but never verified:
186
+ # whoever registered it may not own it, so cancel that registration and let
187
+ # the owner register afresh rather than recover it. Answer your users the
188
+ # same way in every case.
189
+ def issue_recovery(identity:)
190
+ raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
191
+ body = JSON.generate(identity_kind: "email", identity: identity)
192
+ raise InvalidRequest, "recovery request too large" if body.bytesize > 16_384
193
+ response = service_request(:post, "recoveries", body: body, statuses: [201])
194
+ values = object(response, {"token" => :string, "expires_at" => :timestamp, "user_id" => :uuid})
195
+ Recovery.new(token: RecoveryToken.new(values["token"]), expires_at: values["expires_at"],
196
+ user_id: values["user_id"].freeze)
197
+ rescue JSON::GeneratorError
198
+ raise InvalidRequest, "invalid recovery payload", cause: nil
199
+ end
200
+
201
+ # Suspends the account of `user_id` in this namespace's org: every session
202
+ # it holds there ends, its verification and recovery links stop working,
203
+ # and it authenticates nowhere in the org until `reinstate`. Raises
204
+ # `NotFound`, `AccountDisabled`, or `Busy` while one of its sessions is
205
+ # being rotated or logged out (retry).
206
+ def suspend(user_id:)
207
+ account_change("suspensions", user_id, "Suspended")
208
+ end
209
+
210
+ # Lifts a suspension. The sessions and links it ended stay ended.
211
+ def reinstate(user_id:)
212
+ account_change("reinstatements", user_id, "Active")
213
+ end
214
+
215
+ # Ends every live session of `user_id` in this namespace's org, whatever
216
+ # its account's state, and returns how many ended. `Busy` as for `suspend`.
217
+ def revoke_sessions(user_id:)
218
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
219
+ response = service_request(:post, "revocations", body: JSON.generate(user_id: user_id), statuses: [200])
220
+ object(response, {"revoked_sessions" => :count})["revoked_sessions"]
221
+ end
222
+
223
+ # Imports one account with a pre-hashed password (bcrypt or Argon2id), keyed
224
+ # by your `source` and `external_id`, as `strongroom-admin provision` does;
225
+ # the namespace needs `import = true`. Every state is explicit: the kernel
226
+ # refuses MFA and unverified identities. Replaying the same import returns
227
+ # its receipt (`created: false`); a changed one raises `BindingConflict`,
228
+ # an address another account holds `IdentityTaken`.
229
+ def import_account(source:, external_id:, identity:, password_hash:, account_state:, grant_state:,
230
+ identity_state:, credential_state:, mfa_required:, identity_kind: "email")
231
+ fields = {source: source, external_id: external_id, identity_kind: identity_kind, identity: identity,
232
+ account_state: account_state, grant_state: grant_state, identity_state: identity_state,
233
+ credential_state: credential_state, password_hash: password_hash}
234
+ unless fields.values.all?(String) && [true, false].include?(mfa_required)
235
+ raise InvalidRequest, "import fields must be strings and mfa_required a boolean"
236
+ end
237
+ body = JSON.generate(fields.merge(mfa_required: mfa_required))
238
+ raise InvalidRequest, "import request too large" if body.bytesize > 16_384
239
+ response = service_request(:post, "imports", body: body, statuses: [200, 201])
240
+ values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "credential_id" => :uuid})
241
+ Import.new(user_id: values["user_id"], identity_id: values["identity_id"],
242
+ credential_id: values["credential_id"], created: response.status == 201)
243
+ rescue JSON::GeneratorError
244
+ raise InvalidRequest, "invalid import payload", cause: nil
245
+ end
246
+
247
+ # Replaces the password of the account an import bound to `source` and
248
+ # `external_id` with `password_hash`, but only while its active credential
249
+ # still holds `expected_password_hash`, the hash you imported or last
250
+ # replaced. Once the kernel owns the password (a login upgraded its hash, a
251
+ # recovery or password change replaced it) this raises `CredentialChanged`
252
+ # and changes nothing. Its sessions end with the old credential.
253
+ def replace_imported_password(source:, external_id:, expected_password_hash:, password_hash:)
254
+ fields = {source: source, external_id: external_id, expected_password_hash: expected_password_hash,
255
+ password_hash: password_hash}
256
+ raise InvalidRequest, "replacement fields must be strings" unless fields.values.all?(String)
257
+ body = JSON.generate(fields)
258
+ raise InvalidRequest, "replacement request too large" if body.bytesize > 16_384
259
+ response = service_request(:post, "imports/password", body: body, statuses: [200])
260
+ values = object(response, {"status" => :string, "user_id" => :uuid, "identity_id" => :uuid,
261
+ "credential_id" => :uuid, "revoked_credential_id" => :uuid_or_nil})
262
+ unless (values["status"] == "replaced") == !values["revoked_credential_id"].nil? &&
263
+ %w[replaced unchanged].include?(values["status"])
264
+ raise ProtocolError, "invalid service response"
265
+ end
266
+ PasswordReplacement.new(**values.transform_keys(&:to_sym))
267
+ rescue JSON::GeneratorError
268
+ raise InvalidRequest, "invalid replacement payload", cause: nil
269
+ end
270
+
154
271
  # One page of the namespace's event feed, in commit-safe order.
155
272
  def events(after: nil, limit: 100)
156
273
  unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
@@ -170,6 +287,16 @@ module Strongroom
170
287
 
171
288
  private
172
289
 
290
+ def account_change(suffix, user_id, state)
291
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
292
+ response = service_request(:post, suffix, body: JSON.generate(user_id: user_id), statuses: [200])
293
+ schema = {"state" => :string, "changed" => :boolean}
294
+ schema["revoked_sessions"] = :count if state == "Suspended"
295
+ values = object(response, schema)
296
+ raise ProtocolError, "invalid service response" unless values["state"] == state
297
+ AccountChange.new(state: state, changed: values["changed"], revoked_sessions: values.fetch("revoked_sessions", 0))
298
+ end
299
+
173
300
  def configure_service(endpoint, key, transport)
174
301
  if endpoint.nil? && key.nil? && transport.nil?
175
302
  @service_endpoint = @service_key = @service_transport = nil
@@ -244,6 +371,8 @@ module Strongroom
244
371
  case response.status
245
372
  when 401 then raise Unauthorized, "authentication failed"
246
373
  when 403 then raise ProtocolError, "authentication request refused by service policy"
374
+ when 409 then conflict!(response)
375
+ when 422 then invalid_password!(response)
247
376
  when 429 then raise RateLimited.new(retry_after: retry_after(response))
248
377
  when 503 then raise Busy.new(retry_after: retry_after(response))
249
378
  when 500..599 then raise Unavailable, "authentication service unavailable"
@@ -273,16 +402,19 @@ module Strongroom
273
402
  when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
274
403
  when 404 then raise NotFound, "not found in this namespace"
275
404
  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"
405
+ when 422 then invalid_password!(response)
280
406
  when 429 then raise RateLimited.new(retry_after: retry_after(response))
281
407
  when 503 then raise Busy.new(retry_after: retry_after(response))
282
408
  when 500..599 then raise Unavailable, "authentication service unavailable"
283
409
  end
284
410
  end
285
411
 
412
+ def invalid_password!(response)
413
+ reason = service_error(response)&.fetch("reason", nil)
414
+ raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
415
+ raise ProtocolError, "invalid authentication response"
416
+ end
417
+
286
418
  def conflict!(response)
287
419
  case service_error(response)&.fetch("error", nil)
288
420
  when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
@@ -290,6 +422,10 @@ module Strongroom
290
422
  when "already_verified" then raise AlreadyVerified, "identity is already verified"
291
423
  when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
292
424
  when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
425
+ when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
426
+ when "credential_changed" then raise CredentialChanged, "the identity's password credential changed"
427
+ when "account_disabled" then raise AccountDisabled, "account is disabled"
428
+ when "no_active_credential" then raise NoActiveCredential, "account has no active password"
293
429
  else raise ProtocolError, "invalid service response"
294
430
  end
295
431
  end
@@ -347,6 +483,7 @@ module Strongroom
347
483
  when :uuid then canonical_uuid?(value)
348
484
  when :boolean then value == true || value == false
349
485
  when :uuid_or_nil then value.nil? || canonical_uuid?(value)
486
+ when :count then value.is_a?(Integer) && value >= 0
350
487
  else value.is_a?(String)
351
488
  end
352
489
  end
@@ -16,6 +16,22 @@ module Strongroom
16
16
  class AlreadyVerified < Conflict; end
17
17
  class IdentityDisabled < Conflict; end
18
18
 
19
+ # A password change for an unverified identity: nobody has proven they own
20
+ # the address, so knowing its password proves nothing. Nothing changed.
21
+ class IdentityUnverified < Conflict; end
22
+
23
+ # A password change from a session issued with a password the identity no
24
+ # longer logs in with: sign in again, then change it. For an imported
25
+ # password replacement: the kernel's credential no longer holds the expected
26
+ # hash, so the kernel owns that password now. Nothing changed.
27
+ class CredentialChanged < Conflict; end
28
+
29
+ # A suspension or reinstatement of an account that is disabled, which is final.
30
+ class AccountDisabled < Conflict; end
31
+
32
+ # An imported password replacement for an account without an active password.
33
+ class NoActiveCredential < Conflict; end
34
+
19
35
  # The feed cursor names a point the kernel's database has not reached (it was
20
36
  # restored, failed over or copied): drop the cursor and read from the start.
21
37
  class CursorAhead < Conflict; end
@@ -48,11 +48,38 @@ module Strongroom
48
48
  client.issue_verification(user_id: user_id)
49
49
  end
50
50
 
51
+ # A recovery link for the account holding `identity`; needs only the
52
+ # service key, so imported and registered accounts alike can recover.
53
+ def issue_recovery(identity:)
54
+ client.issue_recovery(identity: identity)
55
+ end
56
+
57
+ # Changes the password of `token`'s session and returns it rotated onto
58
+ # the new one; prefer the controller helper, which also replaces the cookie.
59
+ def change_password(token:, current_password:, new_password:)
60
+ client.change_password(token: token, current_password: current_password, new_password: new_password)
61
+ end
62
+
51
63
  # The namespace's event feed; needs only the service key.
52
64
  def events(after: nil, limit: 100)
53
65
  client.events(after: after, limit: limit)
54
66
  end
55
67
 
68
+ # Suspends the user's account in the namespace's org, ending its sessions
69
+ # and links; needs only the service key.
70
+ def suspend(user_id:)
71
+ client.suspend(user_id: user_id)
72
+ end
73
+
74
+ def reinstate(user_id:)
75
+ client.reinstate(user_id: user_id)
76
+ end
77
+
78
+ # Ends every live session of the user in the namespace's org.
79
+ def revoke_sessions(user_id:)
80
+ client.revoke_sessions(user_id: user_id)
81
+ end
82
+
56
83
  def resolve(session)
57
84
  subject = client.subject(session)
58
85
  record = @resolver.call(subject)
@@ -50,6 +50,22 @@ module Strongroom
50
50
  strongroom_resolver.forget(namespace)
51
51
  end
52
52
 
53
+ # Changes the password of the request's session and replaces the cookie
54
+ # with the session rotated onto the new password. Every other session of
55
+ # the user in the org ends; a wrong current password raises
56
+ # `Unauthorized`, an unverified identity `IdentityUnverified`, and
57
+ # neither changes anything.
58
+ def strongroom_change_password(namespace:, current_password:, new_password:)
59
+ strongroom_verify_mutation!(:post)
60
+ account = strongroom_registry.fetch(namespace)
61
+ token = strongroom_resolver.credential(namespace) or raise Unauthorized, "authentication failed"
62
+ changed = account.change_password(token: token, current_password: current_password,
63
+ new_password: new_password)
64
+ strongroom_publish(account, changed)
65
+ ensure
66
+ strongroom_resolver.forget(namespace)
67
+ end
68
+
53
69
  def strongroom_logout(namespace:)
54
70
  strongroom_verify_mutation!(:delete)
55
71
  account = strongroom_registry.fetch(namespace)
@@ -17,6 +17,8 @@ module Strongroom
17
17
  "Strongroom::AlreadyVerified" => :conflict,
18
18
  "Strongroom::IdentityDisabled" => :conflict,
19
19
  "Strongroom::CursorAhead" => :conflict,
20
+ "Strongroom::IdentityUnverified" => :conflict,
21
+ "Strongroom::CredentialChanged" => :conflict,
20
22
  "Strongroom::RateLimited" => :too_many_requests,
21
23
  "Strongroom::Busy" => :service_unavailable,
22
24
  "Strongroom::ProtocolError" => :service_unavailable,
@@ -25,7 +27,7 @@ module Strongroom
25
27
 
26
28
  initializer "strongroom.filter_credentials" do |app|
27
29
  app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token
28
- service_key verification_token]
30
+ service_key verification_token recovery_token]
29
31
  end
30
32
 
31
33
  config.after_initialize do |app|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Strongroom
4
4
  module Rails
5
- VERSION = "0.2.0" # x-release-please-version
5
+ VERSION = "0.4.0" # x-release-please-version
6
6
  end
7
7
  end
@@ -10,6 +10,20 @@ module Strongroom
10
10
  # changed nothing; `identity_state` is the identity's current state.
11
11
  Registration = Data.define(:user_id, :identity_id, :identity_state, :created)
12
12
 
13
+ # What a suspension or reinstatement did: the account's `state` now
14
+ # ("Suspended" or "Active"), whether this call `changed` it, and how many
15
+ # sessions a suspension ended (0 for a reinstatement or a repeat).
16
+ AccountChange = Data.define(:state, :changed, :revoked_sessions)
17
+
18
+ # An import's receipt. `created` is false for an exact replay, which changed
19
+ # nothing.
20
+ Import = Data.define(:user_id, :identity_id, :credential_id, :created)
21
+
22
+ # A replaced imported password. `status` is "replaced" (the credential
23
+ # `revoked_credential_id` was revoked for `credential_id`) or "unchanged"
24
+ # (the active credential held that hash already; `revoked_credential_id` nil).
25
+ PasswordReplacement = Data.define(:status, :user_id, :identity_id, :credential_id, :revoked_credential_id)
26
+
13
27
  # One account event from the namespace's feed. `meta` is a frozen Hash of
14
28
  # non-secret IDs (session, credential, token).
15
29
  Event = Data.define(:id, :type, :user_id, :identity_id, :at, :meta)
@@ -37,10 +51,10 @@ module Strongroom
37
51
  end
38
52
  end
39
53
 
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
54
+ # A link's secret and its expiry. Put the token in the URL fragment of a link
55
+ # to your own page, which posts it to the kernel; nothing here is safe to log
42
56
  # or serialize.
43
- class Verification
57
+ class Link
44
58
  attr_reader :token, :expires_at
45
59
 
46
60
  def initialize(token:, expires_at:)
@@ -48,16 +62,33 @@ module Strongroom
48
62
  freeze
49
63
  end
50
64
 
51
- def inspect = "#<Strongroom::Verification expires_at=#{expires_at.iso8601} token=[FILTERED]>"
65
+ def inspect = "#<#{self.class.name} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
52
66
  alias to_s inspect
53
67
  def pretty_print(printer) = printer.text(inspect)
54
68
 
55
69
  def as_json(*)
56
- raise TypeError, "verification links must not be serialized"
70
+ raise TypeError, "links must not be serialized"
57
71
  end
58
72
 
59
73
  def marshal_dump
60
- raise TypeError, "verification links must not be serialized"
74
+ raise TypeError, "links must not be serialized"
75
+ end
76
+ end
77
+
78
+ # A verification link: its page's button confirms the address.
79
+ class Verification < Link; end
80
+
81
+ # A recovery link for `user_id`: its page asks for a new password and posts
82
+ # both to the kernel.
83
+ class Recovery < Link
84
+ attr_reader :user_id
85
+
86
+ def initialize(token:, expires_at:, user_id:)
87
+ @user_id = user_id
88
+ super(token: token, expires_at: expires_at)
61
89
  end
90
+
91
+ def inspect = "#<Strongroom::Recovery user_id=#{user_id} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
92
+ alias to_s inspect
62
93
  end
63
94
  end
@@ -75,6 +75,12 @@ module Strongroom
75
75
  def self.refusal = [ProtocolError, "invalid verification token"]
76
76
  end
77
77
 
78
+ # A recovery link's secret, `srr1_...`: mail it, never log it.
79
+ class RecoveryToken < Credential
80
+ def self.prefix = "srr1_"
81
+ def self.refusal = [ProtocolError, "invalid recovery token"]
82
+ end
83
+
78
84
  # A namespace's service key, `srk1_...`, sent only to the service listener.
79
85
  class ServiceKey < Credential
80
86
  def self.prefix = "srk1_"
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seuros