strongroom-rails 0.1.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: d266097d730b4561dc0e197cbfb8cb6811626b1cfea5defd21b7f018f0e39ed0
4
- data.tar.gz: efc7b6166160438836119409e1c836179daa40b33913dbe4f6a9464eff9f53d3
3
+ metadata.gz: 2313700e0514a62dbf3f58e5f541e0c4421f4c034a8220840cd9ca243d05b816
4
+ data.tar.gz: 5530d843f7630a021488e36bcc230bdfd9d1137f3aec6c5a1dd8d233c3a9b09f
5
5
  SHA512:
6
- metadata.gz: d9200dd2cdf23a5822286500ee92e35c254d824a2d910c8039df5bfdab0f05402a51ed2dc6e601a7069db72584353d0e8a05681d2ff4b223b4040cd15f8ab6fe
7
- data.tar.gz: c109f7251482e29b24b2349ff5cce04958207eaca352d90d5a7b0311d38ca0abc7e55a763e5ec7f25309b323ee092ae8b3e6b415ac675322803e12da3cc5727b
6
+ metadata.gz: 5b77a81722924260a13456a6416012c2670dafe0360cb8932f21c8fd3250301a50564fd7d86e7c26425e741b5fd781a08b694a812357afece92dec857570b881
7
+ data.tar.gz: fce5256ee8f35e3f44e70165c0615a0d5c3a0ddc61b05d5b976cf7918368f425d0b0ef026d7009fb1c7a44a733bb0b36f8849b56eda5ee73b5cb32c2b25b4127
data/CHANGELOG.md CHANGED
@@ -1 +1,17 @@
1
1
  # Changelog
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
+
10
+ ## [0.2.0](https://github.com/seuros/strongroom/compare/strongroom-rails-v0.1.0...strongroom-rails/v0.2.0) (2026-10-25)
11
+
12
+
13
+ ### Features
14
+
15
+ * add self-service registration, email verification links, an unverified-login grace policy and an event feed ([b39e976](https://github.com/seuros/strongroom/commit/b39e976600085752350b333a3c82a00e4a0493bf))
16
+
17
+ ## Changelog
data/README.md CHANGED
@@ -1,9 +1,10 @@
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
6
- 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.
7
8
 
8
9
  > **Status: pre-production.** Strongroom has not been audited and is not approved
9
10
  > for production traffic or real credential imports.
@@ -30,6 +31,11 @@ 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`.
37
+ - [x] Self-service password change and password recovery links (see
38
+ [Password change and recovery](#password-change-and-recovery)).
33
39
 
34
40
  It never finds users by email, creates local accounts, dynamically constantizes
35
41
  table names, overrides `current_user`, installs routes or falls back to legacy
@@ -99,7 +105,10 @@ authenticate (for example an asset build without credentials) can opt out with
99
105
  Add additional `Account` objects to the registry for other approved namespaces.
100
106
  At most 16 are supported; names, tables and same-issuer audiences must be distinct.
101
107
  Pass actual model classes, never class names from parameters/configuration.
102
- `register` defaults false; true records future policy only and adds no route.
108
+ `register` defaults false; `register: true` needs a client configured with
109
+ `service_endpoint:` and `service_key:` and enables `register_account` and
110
+ `issue_verification` on the account; `issue_recovery` and `events` need only
111
+ the service key. The gem still installs no route.
103
112
 
104
113
  Controllers opt in explicitly and keep their own frontend response contract:
105
114
 
@@ -281,6 +290,198 @@ sent on a connection the server then dropped raises `Unavailable`. Pools are
281
290
  per process; a forked child never touches its parent's sockets. Call
282
291
  `transport.close` to drop idle connections at shutdown.
283
292
 
293
+ ## Registration
294
+
295
+ The kernel serves registration on its service listener (`server.service_listen`,
296
+ loopback or a private network), authenticated by the namespace's service key.
297
+ Give the client both; the key never appears in `inspect`, `pp` or YAML:
298
+
299
+ ```ruby
300
+ client = Strongroom::Client.new(
301
+ endpoint: ENV.fetch("STRONGROOM_ENDPOINT"), issuer: "https://auth.example.com", namespace: "users",
302
+ org_id: ENV.fetch("STRONGROOM_USERS_ORG_ID"), app_id: ENV.fetch("STRONGROOM_USERS_APP_ID"),
303
+ service_endpoint: ENV.fetch("STRONGROOM_SERVICE_ENDPOINT"), service_key: ENV.fetch("STRONGROOM_USERS_SERVICE_KEY")
304
+ )
305
+ users = Strongroom::Rails::Account.new(client: client, table: "users", model: User, register: true,
306
+ resolve: ..., eligible: ...)
307
+
308
+ registration = users.register_account(external_id: user.id.to_s, identity: params[:email],
309
+ password: params[:password])
310
+ registration.created # false for a replay of the same external_id and address
311
+ VerificationMailer.with(user: user).confirm.deliver_later # the user, never the token
312
+ ```
313
+
314
+ Issue the link where the mail is built, so the token never becomes a job
315
+ argument (ActiveJob stores and logs those):
316
+
317
+ ```ruby
318
+ class VerificationMailer < ApplicationMailer
319
+ def confirm
320
+ user = params.fetch(:user)
321
+ link = Rails.configuration.x.strongroom_accounts.fetch(:users)
322
+ .issue_verification(user_id: user.strongroom_user_id)
323
+ # The token goes in the fragment of a link to your own page: browsers never
324
+ # send a fragment, so neither your logs nor a Referer header see it.
325
+ @url = "https://app.example.com/email/confirm##{link.token.reveal}"
326
+ mail(to: user.email, subject: "Confirm your address")
327
+ end
328
+ end
329
+ ```
330
+
331
+ If the token must travel through a job, pass it only in an argument your own
332
+ `ActiveJob::Serializers::ObjectSerializer` encrypts (with
333
+ `ActiveSupport::MessageEncryptor`), or call `deliver_now` from the request.
334
+ Never pass the URL or `link.token.reveal` to `deliver_later`.
335
+
336
+ The page at `/email/confirm` shows a button; its click posts the fragment to
337
+ the kernel's public listener (the gem does not proxy it). List the page's origin
338
+ in the kernel's `[browser] allowed_origins`:
339
+
340
+ ```js
341
+ await fetch(`${STRONGROOM_ORIGIN}/accounts/users/verify`, {
342
+ method: "POST",
343
+ headers: { "Content-Type": "application/json", "X-Strongroom-Request": "1" },
344
+ body: JSON.stringify({ token: location.hash.slice(1) }),
345
+ }); // 200 {"status": "verified" | "already_verified"}, 400 {"error": "invalid_token", "reason": ...}
346
+ ```
347
+
348
+ A mail scanner that fetches the link only fetches your page: nothing it sends
349
+ verifies the address.
350
+
351
+ - `register` (on the client; `register_account` on an account) creates the
352
+ account with an unverified email identity. Replaying the same `external_id`
353
+ and address returns the original receipt and changes nothing, before any
354
+ password check. It raises `Strongroom::BindingConflict` (the external ID is
355
+ bound to another address), `Strongroom::IdentityTaken` (another account holds
356
+ the address) or `Strongroom::InvalidPassword` with `reason` `too_short`,
357
+ `too_long` or `invalid`. `IdentityTaken` tells your server an address has an
358
+ account: do not tell the person signing up.
359
+ - `cancel_registration(external_id:)` deletes a registration whose address was
360
+ never verified (a squatted address, a stale sign-up); the address and the
361
+ external ID are free again. It raises `Strongroom::AlreadyVerified`,
362
+ `Strongroom::IdentityDisabled`, `Strongroom::NotFound`, or `Strongroom::Busy`
363
+ while one of its sessions is mid-rotation (retry).
364
+ - `issue_verification(user_id:)` returns a `Strongroom::Verification` whose
365
+ `token` is redacted everywhere and is never a session credential. Earlier
366
+ links stop working. It raises `Strongroom::AlreadyVerified` or
367
+ `Strongroom::NotFound`.
368
+ - `events(after: nil, limit: 100)` returns a `Strongroom::EventPage` of
369
+ `Strongroom::Event`s (`Provision`, `Register`, `RegistrationCancel`,
370
+ `VerificationIssue`, `IdentityVerify`, `PasswordHashUpgrade`,
371
+ `PasswordReplace`, `Revoke`, `RecoveryIssue`, `RecoveryComplete`,
372
+ `PasswordChange`); keep `page.next` and pass it as `after:`.
373
+ `next` is `nil` for an empty page: ask again later with the cursor you have.
374
+ `page.oldest_at` is the oldest event the kernel still holds; if your last
375
+ event is older, retention pruned events you never read. `Strongroom::CursorAhead`
376
+ means the kernel's database is behind your cursor (restored or failed over):
377
+ drop the cursor and read from the start.
378
+ - `Strongroom::Session#identity_verified` is `false` only for an unverified
379
+ identity inside the namespace's `unverified_grace_hours`; with the default of
380
+ `0` an unverified identity cannot log in at all.
381
+
382
+ Service-key refusals raise `Strongroom::ProtocolError` (a deployment error, not
383
+ a user's), rate limits `Strongroom::RateLimited`/`Busy` with `retry_after`. The
384
+ Railtie maps `Conflict`s to 409, `InvalidPassword` to 422 and `NotFound` to 404.
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
+
284
485
  ## Running the daemon
285
486
 
286
487
  This gem is only the client: it ships no binary and needs none to install. The
@@ -308,11 +509,14 @@ The two gems are versioned separately.
308
509
 
309
510
  - Keep direct backend access restricted and TLS/service networking trusted.
310
511
  Local eligibility checks do not synchronize kernel account state or revoke
311
- all remote sessions. Password changes/resets, disablement and MFA enablement
312
- still need authoritative credential/state updates and invalidation.
313
- - This release is an existing, confirmed, password-only cohort. Real admins,
314
- MFA-required/unverified accounts and registration remain on their explicitly
315
- selected legacy owner. Do not use failure fallback or mislabel MFA state.
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.
515
+ - Imports remain an existing, confirmed, password-only cohort; registration
516
+ creates unverified accounts the kernel refuses until they are verified (or
517
+ until a configured grace runs out). Real admins and MFA-required accounts
518
+ remain on their explicitly selected legacy owner. Do not use failure fallback
519
+ or mislabel MFA state.
316
520
  - Define old-cookie/JWT retirement and canonical-session-bound compatibility
317
521
  before cutting an existing application over. This gem does not preserve existing JWT/frontend
318
522
  contracts automatically or share Rails signing secrets with Strongroom.
@@ -322,8 +526,8 @@ The two gems are versioned separately.
322
526
  Ruby/parser/network/framework copies cannot be reliably erased. Do not log
323
527
  headers, cookie jars, passwords, transport bodies or explicit token strings.
324
528
  The Railtie adds parameter filters; separately audit proxy/middleware/APM logs.
325
- - Registration/mail/recovery, bulk mapping orchestration, session families,
326
- remote admin operations, deployment SLOs and production approval are not shipped.
529
+ - Mail delivery, bulk mapping orchestration, remote admin operations,
530
+ deployment SLOs and production approval are not shipped.
327
531
 
328
532
  ## Local checks (no installs, network or database required)
329
533
 
@@ -395,7 +599,8 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
395
599
  `__Host-` cookie and a free admin port, and waits for `/admin/ready`. If the
396
600
  daemon exits, the failure carries its stderr (database URL redacted).
397
601
  - Seeds only synthetic org/app bindings, then uses the actual admin CLI to
398
- provision two distinct, confirmed password-only principals with the same email.
602
+ provision three distinct, confirmed password-only principals, two of them with
603
+ the same email in different namespaces.
399
604
  The `admin` fixture is a realm-isolation test, not real admin/MFA migration.
400
605
  - Installs a fresh package in a temporary consumer bundle, then uses its real
401
606
  Net::HTTP client for login, audience isolation, rotation, reuse detection,
@@ -405,8 +610,22 @@ directory; it may also name the RubyGems launchers of an installed `strongroomd`
405
610
  - Exercises the Rails helpers through in-memory Rack/HTTPS requests, encrypted
406
611
  cookies, CSRF and a full-scope trusted mapping into synthetic local records.
407
612
  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.
613
+ - Registers an account through the service listener with a key minted for the
614
+ run, proves replay idempotency and the typed refusals, that the account
615
+ cannot log in before its link is confirmed and that a `GET` of the link does
616
+ nothing, then confirms it by `POST` on the public listener and logs in with
617
+ `identity_verified` true, reads the event feed, and cancels a squatted
618
+ registration so the address can be registered again.
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.
410
629
  - Bounds subprocesses and kills/reaps its owned process groups, drops its schema
411
630
  and removes temporary files on completion or handled failure/interruption.
412
631
  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 RecoveryIssue RecoveryComplete PasswordChange].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
@@ -63,6 +89,32 @@ module Strongroom
63
89
  end
64
90
  end
65
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
+
66
118
  def logout(token:)
67
119
  request(:delete, "auth/logout", token: Token.coerce(token), status: 204)
68
120
  nil
@@ -83,8 +135,99 @@ module Strongroom
83
135
  Subject.new(issuer: issuer, namespace: namespace, org_id: org_id, app_id: app_id, user_id: session.user_id)
84
136
  end
85
137
 
138
+ # Creates an account with an unverified email identity, keyed by your own
139
+ # `external_id`. Replaying the same external ID and identity returns the
140
+ # original receipt (`created: false`) and changes nothing.
141
+ def register(external_id:, identity:, password:)
142
+ unless [external_id, identity, password].all? { |value| value.is_a?(String) }
143
+ raise InvalidRequest, "registration fields must be strings"
144
+ end
145
+ body = JSON.generate(external_id: external_id, identity_kind: "email", identity: identity, password: password)
146
+ raise InvalidRequest, "registration request too large" if body.bytesize > 16_384
147
+ response = service_request(:post, "registrations", body: body, statuses: [200, 201])
148
+ values = object(response, {"user_id" => :uuid, "identity_id" => :uuid, "identity_state" => :string})
149
+ raise ProtocolError, "invalid registration response" unless IDENTITY_STATES.include?(values["identity_state"])
150
+ Registration.new(user_id: values["user_id"], identity_id: values["identity_id"],
151
+ identity_state: values["identity_state"], created: response.status == 201)
152
+ rescue JSON::GeneratorError
153
+ raise InvalidRequest, "invalid registration payload", cause: nil
154
+ end
155
+
156
+ # Withdraws a registration whose address was never verified: the account
157
+ # it created is deleted, and the address and external ID are free again.
158
+ def cancel_registration(external_id:)
159
+ raise InvalidRequest, "external_id must be a string" unless external_id.is_a?(String)
160
+ body = JSON.generate(external_id: external_id)
161
+ response = service_request(:post, "registrations/cancel", body: body, statuses: [200])
162
+ unless object(response, {"status" => :string})["status"] == "cancelled"
163
+ raise ProtocolError, "invalid service response"
164
+ end
165
+ nil
166
+ rescue JSON::GeneratorError
167
+ raise InvalidRequest, "invalid external_id", cause: nil
168
+ end
169
+
170
+ # A fresh verification link for `user_id`'s email identity; earlier links
171
+ # stop working. The browser confirms it: POST {"token"} to
172
+ # `/accounts/<namespace>/verify` on the kernel's public listener.
173
+ def issue_verification(user_id:)
174
+ raise InvalidRequest, "user_id must be a canonical UUID" unless canonical_uuid?(user_id)
175
+ response = service_request(:post, "verifications", body: JSON.generate(user_id: user_id), statuses: [201])
176
+ values = object(response, {"token" => :string, "expires_at" => :timestamp})
177
+ Verification.new(token: VerificationToken.new(values["token"]), expires_at: values["expires_at"])
178
+ end
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
+
198
+ # One page of the namespace's event feed, in commit-safe order.
199
+ def events(after: nil, limit: 100)
200
+ unless (after.nil? || (after.is_a?(String) && after.match?(CURSOR))) &&
201
+ limit.is_a?(Integer) && (1..500).cover?(limit)
202
+ raise InvalidRequest, "after: must be a feed cursor and limit: 1 to 500"
203
+ end
204
+ query = after ? "after=#{after}&limit=#{limit}" : "limit=#{limit}"
205
+ response = service_request(:get, "events?#{query}", statuses: [200])
206
+ page = parse(response)
207
+ unless page.is_a?(Hash) && page.keys.sort == %w[events next oldest_at] && page["events"].is_a?(Array) &&
208
+ (page["next"].nil? || (page["next"].is_a?(String) && page["next"].match?(CURSOR)))
209
+ raise ProtocolError, "invalid event feed"
210
+ end
211
+ EventPage.new(events: page["events"].map { |event| feed_event(event) }.freeze, next: page["next"]&.freeze,
212
+ oldest_at: page["oldest_at"]&.then { timestamp(it) })
213
+ end
214
+
86
215
  private
87
216
 
217
+ def configure_service(endpoint, key, transport)
218
+ if endpoint.nil? && key.nil? && transport.nil?
219
+ @service_endpoint = @service_key = @service_transport = nil
220
+ return
221
+ end
222
+ raise ConfigurationError, "service_endpoint: and service_key: go together" if endpoint.nil? || key.nil?
223
+ @service_endpoint = NetHTTPTransport.endpoint(endpoint)
224
+ @service_key = ServiceKey.new(key)
225
+ @service_transport = transport || NetHTTPTransport.new(endpoint: @service_endpoint)
226
+ unless serves_endpoint?(@service_transport, @service_endpoint)
227
+ raise ConfigurationError, "service transport endpoint does not match the service endpoint"
228
+ end
229
+ end
230
+
88
231
  def issue(response)
89
232
  token = issued_token(response)
90
233
  IssuedSession.new(session: yield(token), token: token)
@@ -95,7 +238,8 @@ module Strongroom
95
238
 
96
239
  def same_login?(declared, current)
97
240
  declared["session_id"] == current.session_id && declared["user_id"] == current.user_id &&
98
- same_instant?(timestamp(declared["expires_at"]), current.expires_at)
241
+ declared["identity_verified"] == current.identity_verified &&
242
+ same_instant?(declared["expires_at"], current.expires_at)
99
243
  end
100
244
 
101
245
  def same_session?(declared, current)
@@ -116,7 +260,7 @@ module Strongroom
116
260
  raise ProtocolError, "invalid authentication response", cause: nil
117
261
  end
118
262
 
119
- def serves_endpoint?(transport)
263
+ def serves_endpoint?(transport, endpoint)
120
264
  transport.respond_to?(:call) && transport.respond_to?(:endpoint) &&
121
265
  NetHTTPTransport.endpoint(transport.endpoint) == endpoint
122
266
  rescue ConfigurationError
@@ -140,23 +284,86 @@ module Strongroom
140
284
  headers["Content-Type"] = "application/json" if body
141
285
  headers["Authorization"] = "Bearer #{token.reveal}" if token
142
286
  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
287
+ bounded!(response)
146
288
  case response.status
147
289
  when 401 then raise Unauthorized, "authentication failed"
148
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)
293
+ when 429 then raise RateLimited.new(retry_after: retry_after(response))
294
+ when 503 then raise Busy.new(retry_after: retry_after(response))
295
+ when 500..599 then raise Unavailable, "authentication service unavailable"
296
+ end
297
+ expected!(response, [status])
298
+ raise ProtocolError, "unexpected logout body" if status == 204 && !response.body.empty?
299
+ response
300
+ end
301
+
302
+ # The namespace's service API: a server-to-server call with the service
303
+ # key, never a browser's. Kernel refusals become typed errors.
304
+ def service_request(method, suffix, statuses:, body: nil)
305
+ raise ConfigurationError, "no service_endpoint: or service_key: configured for #{namespace}" unless service?
306
+ headers = {"Accept" => "application/json", "Accept-Encoding" => "identity",
307
+ "Cache-Control" => "no-store", SERVICE_KEY_HEADER => @service_key.reveal}
308
+ headers["Content-Type"] = "application/json" if body
309
+ response = @service_transport.call(method, "/accounts/#{namespace}/service/#{suffix}", headers: headers, body: body)
310
+ bounded!(response)
311
+ refused!(response) unless statuses.include?(response.status)
312
+ expected!(response, statuses)
313
+ response
314
+ end
315
+
316
+ def refused!(response)
317
+ case response.status
318
+ when 400 then raise InvalidRequest, "service request refused"
319
+ when 401, 403 then raise ProtocolError, "service key or caller refused by the kernel"
320
+ when 404 then raise NotFound, "not found in this namespace"
321
+ when 409 then conflict!(response)
322
+ when 422 then invalid_password!(response)
149
323
  when 429 then raise RateLimited.new(retry_after: retry_after(response))
150
324
  when 503 then raise Busy.new(retry_after: retry_after(response))
151
325
  when 500..599 then raise Unavailable, "authentication service unavailable"
152
326
  end
327
+ end
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
+
335
+ def conflict!(response)
336
+ case service_error(response)&.fetch("error", nil)
337
+ when "identity_taken" then raise IdentityTaken, "identity already belongs to another account"
338
+ when "binding_conflict" then raise BindingConflict, "external_id is bound to a different identity"
339
+ when "already_verified" then raise AlreadyVerified, "identity is already verified"
340
+ when "identity_disabled" then raise IdentityDisabled, "identity is disabled"
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"
344
+ else raise ProtocolError, "invalid service response"
345
+ end
346
+ end
347
+
348
+ def service_error(response)
349
+ error = parse(response)
350
+ error if error.is_a?(Hash) && error.values.all?(String)
351
+ rescue ProtocolError
352
+ nil
353
+ end
354
+
355
+ def bounded!(response)
356
+ unless response.is_a?(TransportResponse) && response.body.bytesize <= NetHTTPTransport::MAX_RESPONSE_BYTES
357
+ raise ProtocolError, "invalid authentication response"
358
+ end
359
+ end
360
+
361
+ def expected!(response, statuses)
153
362
  cache = response.headers.fetch("cache-control", []).join(",").split(",").map(&:strip)
154
- unless response.status == status && cache.include?("no-store") &&
363
+ unless statuses.include?(response.status) && cache.include?("no-store") &&
155
364
  response.headers.fetch("content-encoding", ["identity"]) == ["identity"]
156
365
  raise ProtocolError, "unexpected authentication response"
157
366
  end
158
- raise ProtocolError, "unexpected logout body" if status == 204 && !response.body.empty?
159
- response
160
367
  end
161
368
 
162
369
  def retry_after(response)
@@ -166,30 +373,65 @@ module Strongroom
166
373
  value if (1..86_400).cover?(value)
167
374
  end
168
375
 
169
- def object(response, keys)
376
+ def parse(response)
170
377
  types = response.headers.fetch("content-type", [])
171
378
  unless types.length == 1 && types[0].split(";").first.to_s.strip.casecmp?("application/json")
172
379
  raise ProtocolError, "invalid authentication response"
173
380
  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
381
+ JSON.parse(response.body)
179
382
  rescue JSON::ParserError, EncodingError
180
383
  raise ProtocolError, "invalid authentication response", cause: nil
181
384
  end
182
385
 
386
+ # Exactly `schema`'s keys, each of its kind; timestamps parsed.
387
+ def object(response, schema)
388
+ result = parse(response)
389
+ unless result.is_a?(Hash) && result.keys.sort == schema.keys.sort &&
390
+ schema.all? { |key, kind| kind?(result[key], kind) }
391
+ raise ProtocolError, "invalid authentication response"
392
+ end
393
+ result.to_h { |key, value| [key, schema[key] == :timestamp ? timestamp(value) : value] }
394
+ end
395
+
396
+ def kind?(value, kind)
397
+ case kind
398
+ when :uuid then canonical_uuid?(value)
399
+ when :boolean then value == true || value == false
400
+ when :uuid_or_nil then value.nil? || canonical_uuid?(value)
401
+ else value.is_a?(String)
402
+ end
403
+ end
404
+
183
405
  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
406
+ values = object(response, METADATA)
407
+ unless values["org_id"] == org_id && values["app_id"] == app_id
187
408
  raise ProtocolError, "unexpected session audience"
188
409
  end
189
- expiry = timestamp(values.fetch("expires_at"))
410
+ expiry = values.fetch("expires_at")
190
411
  raise Unauthorized, "session expired" unless expiry > Time.now
191
412
  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)
413
+ org_id: org_id, app_id: app_id, expires_at: expiry, identity_verified: values["identity_verified"])
414
+ end
415
+
416
+ EVENT = {"id" => :uuid, "type" => :string, "user_id" => :uuid_or_nil, "identity_id" => :uuid_or_nil,
417
+ "at" => :timestamp, "meta" => :meta}.freeze
418
+ private_constant :EVENT
419
+
420
+ def feed_event(event)
421
+ unless event.is_a?(Hash) && event.keys.sort == EVENT.keys.sort && EVENT_TYPES.include?(event["type"]) &&
422
+ event["meta"].is_a?(Hash) && EVENT.all? { |key, kind| kind == :meta || kind?(event[key], kind) }
423
+ raise ProtocolError, "invalid event feed"
424
+ end
425
+ Event.new(id: event["id"].freeze, type: event["type"].freeze, user_id: event["user_id"]&.freeze,
426
+ identity_id: event["identity_id"]&.freeze, at: timestamp(event["at"]), meta: deep_freeze(event["meta"]))
427
+ end
428
+
429
+ def deep_freeze(value)
430
+ case value
431
+ when Hash then value.to_h { |key, item| [key.freeze, deep_freeze(item)] }.freeze
432
+ when Array then value.map { |item| deep_freeze(item) }.freeze
433
+ else value.freeze
434
+ end
193
435
  end
194
436
 
195
437
  def issued_token(response)
@@ -7,6 +7,38 @@ 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
+ # 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
+
27
+ # The feed cursor names a point the kernel's database has not reached (it was
28
+ # restored, failed over or copied): drop the cursor and read from the start.
29
+ class CursorAhead < Conflict; end
30
+
31
+ # A new password the kernel's policy refuses; `reason` is `too_short`,
32
+ # `too_long` or `invalid`. The password itself is never kept.
33
+ class InvalidPassword < InvalidRequest
34
+ REASONS = %w[too_short too_long invalid].freeze
35
+ attr_reader :reason
36
+
37
+ def initialize(reason:)
38
+ @reason = reason
39
+ super("password refused: #{reason}")
40
+ end
41
+ end
10
42
 
11
43
  class RateLimited < Error
12
44
  attr_reader :retry_after
@@ -20,12 +20,51 @@ 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
+ # 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
+
63
+ # The namespace's event feed; needs only the service key.
64
+ def events(after: nil, limit: 100)
65
+ client.events(after: after, limit: limit)
66
+ end
67
+
29
68
  def resolve(session)
30
69
  subject = client.subject(session)
31
70
  record = @resolver.call(subject)
@@ -38,6 +77,12 @@ module Strongroom
38
77
  rescue StandardError
39
78
  raise Unavailable, "local principal lookup unavailable", cause: nil
40
79
  end
80
+
81
+ private
82
+
83
+ def registering!
84
+ raise ConfigurationError, "registration is not enabled for #{client.namespace} (register: false)" unless register
85
+ end
41
86
  end
42
87
  end
43
88
  end
@@ -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)
@@ -8,7 +8,17 @@ 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,
20
+ "Strongroom::IdentityUnverified" => :conflict,
21
+ "Strongroom::CredentialChanged" => :conflict,
12
22
  "Strongroom::RateLimited" => :too_many_requests,
13
23
  "Strongroom::Busy" => :service_unavailable,
14
24
  "Strongroom::ProtocolError" => :service_unavailable,
@@ -16,7 +26,8 @@ module Strongroom
16
26
  )
17
27
 
18
28
  initializer "strongroom.filter_credentials" do |app|
19
- app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token]
29
+ app.config.filter_parameters |= %i[password password_confirmation identity authorization cookie strongroom_token
30
+ service_key verification_token recovery_token]
20
31
  end
21
32
 
22
33
  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.3.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,45 @@ module Strongroom
21
36
  raise TypeError, "issued credentials must not be serialized"
22
37
  end
23
38
  end
39
+
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
+ # or serialize.
43
+ class Link
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 = "#<#{self.class.name} 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, "links must not be serialized"
57
+ end
58
+
59
+ def marshal_dump
60
+ raise TypeError, "links must not be serialized"
61
+ end
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
24
80
  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,12 +35,25 @@ 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"
43
+ end
44
+
45
+ private
46
+
47
+ def refuse
48
+ error, message = self.class.refusal
49
+ raise error, message, cause: nil
38
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"]
39
57
 
40
58
  def self.coerce(value)
41
59
  value.is_a?(self) ? value : new(value)
@@ -50,4 +68,28 @@ 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 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
+
84
+ # A namespace's service key, `srk1_...`, sent only to the service listener.
85
+ class ServiceKey < Credential
86
+ def self.prefix = "srk1_"
87
+ def self.refusal = [ConfigurationError, "service_key: must be srk1_ followed by 43 base64url characters"]
88
+
89
+ def initialize(value)
90
+ super
91
+ # Every byte the same is a placeholder, as the kernel refuses too.
92
+ refuse if Base64.urlsafe_decode64(@wire.delete_prefix("srk1_")).bytes.uniq.size == 1
93
+ end
94
+ end
53
95
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seuros