strongroom-rails 0.2.0 → 0.3.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: 2313700e0514a62dbf3f58e5f541e0c4421f4c034a8220840cd9ca243d05b816
4
+ data.tar.gz: 5530d843f7630a021488e36bcc230bdfd9d1137f3aec6c5a1dd8d233c3a9b09f
5
5
  SHA512:
6
- metadata.gz: 9dd07c5ee1df0e5ec948bd724ea445f45d61d9c34ec0421ab9f838ebbaa3c41d66d0a997c39e6120d6671fb1f35dbe8c14a3b9c8bc46fc42a20e3ae01a92ff3d
7
- data.tar.gz: a4bf94a507478ceeb8f83d5be2e1d3da89afe287e127bfc234d23535338236392db768159dcc80218ff3311c8245f7c5dab908bbdfdd9521ed6bf04b78ff5e9e
6
+ metadata.gz: 5b77a81722924260a13456a6416012c2670dafe0360cb8932f21c8fd3250301a50564fd7d86e7c26425e741b5fd781a08b694a812357afece92dec857570b881
7
+ data.tar.gz: fce5256ee8f35e3f44e70165c0615a0d5c3a0ddc61b05d5b976cf7918368f425d0b0ef026d7009fb1c7a44a733bb0b36f8849b56eda5ee73b5cb32c2b25b4127
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.2.0...strongroom-rails-v0.3.0) (2026-10-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * add password recovery links and self-service password change ([b4f9160](https://github.com/seuros/strongroom/commit/b4f9160b352fb0122086c57a56c701ddb29b461a))
9
+
3
10
  ## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
4
11
 
5
12
 
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,8 @@ 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` and `events` need only
111
+ the service key. The gem still installs no route.
109
112
 
110
113
  Controllers opt in explicitly and keep their own frontend response contract:
111
114
 
@@ -365,7 +368,8 @@ verifies the address.
365
368
  - `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
366
369
  `Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
367
370
  `VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
368
- `PasswordReplace`, `Revoke`); keep `page.next` and pass it as `after:`.
371
+ `PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
372
+ `PasswordChange`); keep `page.next` and pass it as `after:`.
369
373
  `next` is `nil` for an empty page: ask again later with the cursor you have.
370
374
  `page.oldest_at` is the oldest event the kernel still holds; if your last
371
375
  event is older, retention pruned events you never read. `Strongroom::CursorAhead`
@@ -379,6 +383,105 @@ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
379
383
  a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
380
384
  Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
381
385
 
386
+ ## Password change and recovery
387
+
388
+ ### Changing a password
389
+
390
+ A signed-in user changes their password through your controller; the helper
391
+ verifies mutation intent like the others, sends the request's session to the
392
+ kernel and replaces the encrypted cookie with the session the kernel rotated
393
+ onto the new password. The user stays signed in; every other session of theirs
394
+ in the org ends, in every app.
395
+
396
+ ```ruby
397
+ def update # POST; verified CSRF required.
398
+ user = strongroom_change_password(namespace: :users,
399
+ current_password: params.require(:current_password), new_password: params.require(:new_password))
400
+ render json: {id: user.id}
401
+ end
402
+ ```
403
+
404
+ A wrong current password raises `Strongroom::Unauthorized` (401) and changes
405
+ nothing: the session keeps working. A refused new password raises
406
+ `Strongroom::InvalidPassword` with `reason` `too_short`, `too_long` or
407
+ `invalid` (422), before the current password is even checked. An unverified
408
+ identity raises `Strongroom::IdentityUnverified` (409): nobody has proven they
409
+ own the address, so its password proves nothing; have the user confirm it
410
+ first. A session issued with a password the identity no longer logs in with
411
+ raises `Strongroom::CredentialChanged` (409): sign in again. The kernel's
412
+ per-user budget raises `Strongroom::RateLimited` (429). Add your own per-IP
413
+ `rate_limit` too, as for logins. Recovery links the user asked for keep
414
+ working after a change. `client.change_password(token:, current_password:,
415
+ new_password:)` (or `account.change_password`) is the same call without the
416
+ cookie: it returns the rotated `Strongroom::IssuedSession`, confirmed through
417
+ `/me` like a rotation. If that confirmation fails after the kernel made the
418
+ change, the rotated session is logged out before `ProtocolError` is raised:
419
+ the new password is set, and the user signs in with it.
420
+
421
+ ### Recovering a password
422
+
423
+ Any namespace whose client has `service_endpoint:` and `service_key:` can
424
+ issue recovery links, for imported and registered accounts alike; `register:`
425
+ is not needed. Ask for a link where the mail is built, never in the request
426
+ that asked for it, and never hand the token to a job:
427
+
428
+ ```ruby
429
+ class RecoveriesController < ApplicationController
430
+ rate_limit to: 5, within: 10.minutes, by: -> { request.remote_ip }, only: :create
431
+
432
+ def create # POST; the same answer whether or not the address has an account.
433
+ RecoveryMailer.with(email: params.require(:email)).reset.deliver_later # the address, never a token
434
+ head :accepted
435
+ end
436
+ end
437
+
438
+ class RecoveryMailer < ApplicationMailer
439
+ def reset
440
+ users = Rails.configuration.x.strongroom_accounts.fetch(:users)
441
+ link = users.issue_recovery(identity: params.fetch(:email))
442
+ # The fragment of a link to your own page: browsers never send it, so
443
+ # neither your logs nor a Referer header see the token.
444
+ @url = "https://app.example.com/password/reset##{link.token.reveal}"
445
+ mail(to: params.fetch(:email), subject: "Choose a new password")
446
+ rescue Strongroom::NotFound
447
+ # No usable account holds the address: without `mail`, nothing is sent.
448
+ end
449
+ end
450
+ ```
451
+
452
+ `issue_recovery(identity:)` returns a `Strongroom::Recovery` (`token`, a
453
+ `Strongroom::RecoveryToken` redacted everywhere and never a session credential;
454
+ `expires_at`, by default 30 minutes away; `user_id`). Earlier links of that
455
+ address stop working. It raises `Strongroom::NotFound` when no usable account
456
+ holds the address in the namespace: answer your users the same way either way,
457
+ or the form tells anyone which addresses have accounts. If the token must
458
+ travel through a job after all, only in an argument your own
459
+ `ActiveJob::Serializers::ObjectSerializer` encrypts.
460
+
461
+ The page at `/password/reset` asks for the new password and posts it with the
462
+ fragment to the kernel's public listener (list the page's origin in its
463
+ `[browser] allowed_origins`; the gem does not proxy it):
464
+
465
+ ```js
466
+ const reply = await fetch(`${STRONGROOM_ORIGIN}/accounts/users/recovery`, {
467
+ method: "POST",
468
+ headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
469
+ body: JSON.stringify({ token: location.hash.slice(1), password: form.password.value }),
470
+ }); // 200 {"status": "recovered"}; 422 {"error": "invalid_password", "reason": ...} keeps the link
471
+ // usable; 400 {"error": "invalid_token", "reason": "invalid" | "expired"}; 503: retry
472
+ ```
473
+
474
+ A completed recovery sets the new password, ends every session of the user in
475
+ the org (in every app) and their other recovery links, and verifies an address
476
+ that was never verified: following the link proved the mailbox. Send the
477
+ person to your sign-in page afterwards. A second use of the link answers
478
+ `invalid_token`. Links belong to the address across every app of the org.
479
+
480
+ Do not offer recovery for an address whose registration is still unverified:
481
+ the owner would inherit the account whoever registered it prepared (your
482
+ records for that `external_id`), and `cancel_registration` refuses it once
483
+ verified. Cancel such a registration and let the owner register afresh.
484
+
382
485
  ## Running the daemon
383
486
 
384
487
  This gem is only the client: it ships no binary and needs none to install. The
@@ -406,8 +509,9 @@ The two gems are versioned separately.
406
509
 
407
510
  - Keep direct backend access restricted and TLS/service networking trusted.
408
511
  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.
512
+ all remote sessions. Disablement and MFA enablement still need authoritative
513
+ credential/state updates and invalidation; password changes and recoveries
514
+ through the kernel revoke its sessions themselves.
411
515
  - Imports remain an existing, confirmed, password-only cohort; registration
412
516
  creates unverified accounts the kernel refuses until they are verified (or
413
517
  until a configured grace runs out). Real admins and MFA-required accounts
@@ -422,8 +526,8 @@ The two gems are versioned separately.
422
526
  Ruby/parser/network/framework copies cannot be reliably erased. Do not log
423
527
  headers, cookie jars, passwords, transport bodies or explicit token strings.
424
528
  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.
529
+ - Mail delivery, bulk mapping orchestration, remote admin operations,
530
+ deployment SLOs and production approval are not shipped.
427
531
 
428
532
  ## Local checks (no installs, network or database required)
429
533
 
@@ -495,7 +599,8 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
495
599
  `__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
496
600
  daemon exits, the failure carries its stderr (database URL redacted).
497
601
  - 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.
602
+ provision three distinct, confirmed password-only principals, two of them with
603
+ the same email in different namespaces.
499
604
  The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
500
605
  - Installs a fresh package in a temporary consumer bundle, then uses its real
501
606
  Net::HTTP client for login, audience isolation, rotation, reuse detection,
@@ -511,9 +616,16 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
511
616
  nothing, then confirms it by `POST` on the public listener and logs in with
512
617
  `identity_verified` true, reads the event feed, and cancels a squatted
513
618
  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.
619
+ - Changes a registered account's password: a wrong current password and a weak
620
+ new one change nothing, the session that asked continues on the new
621
+ password, its other session and the old password stop working. Recovers a
622
+ registered account (refused weak password, one-time link, every session
623
+ ended, the new password logs in), an address that was never verified (the
624
+ recovery verifies it) and an imported bcrypt account.
625
+ - Checks the database for the imports, the verified registrations, the bcrypt
626
+ upgrades, the rotations and no live sessions, and the daemon's
627
+ `/admin/metrics` for the exact login, registration, verification, recovery
628
+ and password change outcomes.
517
629
  - Bounds subprocesses and kills/reaps its owned process groups, drops its schema
518
630
  and removes temporary files on completion or handled failure/interruption.
519
631
  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].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,24 @@ 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: answer your users
185
+ # the same way either way.
186
+ def issue_recovery(identity:)
187
+ raise InvalidRequest, "identity must be a string" unless identity.is_a?(String)
188
+ body = JSON.generate(identity_kind: "email", identity: identity)
189
+ raise InvalidRequest, "recovery request too large" if body.bytesize > 16_384
190
+ response = service_request(:post, "recoveries", body: body, statuses: [201])
191
+ values = object(response, {"token" => :string, "expires_at" => :timestamp, "user_id" => :uuid})
192
+ Recovery.new(token: RecoveryToken.new(values["token"]), expires_at: values["expires_at"],
193
+ user_id: values["user_id"].freeze)
194
+ rescue JSON::GeneratorError
195
+ raise InvalidRequest, "invalid recovery payload", cause: nil
196
+ end
197
+
154
198
  # One page of the namespace's event feed, in commit-safe order.
155
199
  def events(after: nil, limit: 100)
156
200
  unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
@@ -244,6 +288,8 @@ module Strongroom
244
288
  case response.status
245
289
  when 401 then raise Unauthorized, "authentication failed"
246
290
  when 403 then raise ProtocolError, "authentication request refused by service policy"
291
+ when 409 then conflict!(response)
292
+ when 422 then invalid_password!(response)
247
293
  when 429 then raise RateLimited.new(retry_after: retry_after(response))
248
294
  when 503 then raise Busy.new(retry_after: retry_after(response))
249
295
  when 500..599 then raise Unavailable, "authentication service unavailable"
@@ -273,16 +319,19 @@ module Strongroom
273
319
  when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
274
320
  when 404 then raise NotFound, "not found in this namespace"
275
321
  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"
322
+ when 422 then invalid_password!(response)
280
323
  when 429 then raise RateLimited.new(retry_after: retry_after(response))
281
324
  when 503 then raise Busy.new(retry_after: retry_after(response))
282
325
  when 500..599 then raise Unavailable, "authentication service unavailable"
283
326
  end
284
327
  end
285
328
 
329
+ def invalid_password!(response)
330
+ reason = service_error(response)&.fetch("reason", nil)
331
+ raise InvalidPassword.new(reason: reason) if InvalidPassword::REASONS.include?(reason)
332
+ raise ProtocolError, "invalid authentication response"
333
+ end
334
+
286
335
  def conflict!(response)
287
336
  case service_error(response)&.fetch("error", nil)
288
337
  when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
@@ -290,6 +339,8 @@ module Strongroom
290
339
  when "already_verified" then raise AlreadyVerified, "identity is already verified"
291
340
  when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
292
341
  when "cursor_ahead" then raise CursorAhead, "feed cursor is ahead of the kernel's database"
342
+ when "identity_unverified" then raise IdentityUnverified, "identity is not verified"
343
+ when "credential_changed" then raise CredentialChanged, "session predates the identity's password"
293
344
  else raise ProtocolError, "invalid service response"
294
345
  end
295
346
  end
@@ -16,6 +16,14 @@ 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. Nothing changed.
25
+ class CredentialChanged < Conflict; end
26
+
19
27
  # The feed cursor names a point the kernel's database has not reached (it was
20
28
  # restored, failed over or copied): drop the cursor and read from the start.
21
29
  class CursorAhead < Conflict; end
@@ -48,6 +48,18 @@ 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)
@@ -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.3.0" # x-release-please-version
6
6
  end
7
7
  end
@@ -37,10 +37,10 @@ module Strongroom
37
37
  end
38
38
  end
39
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
40
+ # A link's secret and its expiry. Put the token in the URL fragment of a link
41
+ # to your own page, which posts it to the kernel; nothing here is safe to log
42
42
  # or serialize.
43
- class Verification
43
+ class Link
44
44
  attr_reader :token, :expires_at
45
45
 
46
46
  def initialize(token:, expires_at:)
@@ -48,16 +48,33 @@ module Strongroom
48
48
  freeze
49
49
  end
50
50
 
51
- def inspect = "#<Strongroom::Verification expires_at=#{expires_at.iso8601} token=[FILTERED]>"
51
+ def inspect = "#<#{self.class.name} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
52
52
  alias to_s inspect
53
53
  def pretty_print(printer) = printer.text(inspect)
54
54
 
55
55
  def as_json(*)
56
- raise TypeError, "verification links must not be serialized"
56
+ raise TypeError, "links must not be serialized"
57
57
  end
58
58
 
59
59
  def marshal_dump
60
- raise TypeError, "verification links must not be serialized"
60
+ raise TypeError, "links must not be serialized"
61
61
  end
62
62
  end
63
+
64
+ # A verification link: its page's button confirms the address.
65
+ class Verification < Link; end
66
+
67
+ # A recovery link for `user_id`: its page asks for a new password and posts
68
+ # both to the kernel.
69
+ class Recovery < Link
70
+ attr_reader :user_id
71
+
72
+ def initialize(token:, expires_at:, user_id:)
73
+ @user_id = user_id
74
+ super(token: token, expires_at: expires_at)
75
+ end
76
+
77
+ def inspect = "#<Strongroom::Recovery user_id=#{user_id} expires_at=#{expires_at.iso8601} token=[FILTERED]>"
78
+ alias to_s inspect
79
+ end
63
80
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seuros