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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +233 -14
- data/lib/strongroom/client.rb +266 -24
- data/lib/strongroom/errors.rb +32 -0
- data/lib/strongroom/rails/account.rb +45 -0
- data/lib/strongroom/rails/authentication.rb +16 -0
- data/lib/strongroom/rails/railtie.rb +12 -1
- data/lib/strongroom/rails/version.rb +1 -1
- data/lib/strongroom/session.rb +57 -1
- data/lib/strongroom/token.rb +53 -11
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2313700e0514a62dbf3f58e5f541e0c4421f4c034a8220840cd9ca243d05b816
|
|
4
|
+
data.tar.gz: 5530d843f7630a021488e36bcc230bdfd9d1137f3aec6c5a1dd8d233c3a9b09f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 —
|
|
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
|
|
6
|
-
|
|
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
|
|
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.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
-
|
|
326
|
-
|
|
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
|
|
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
|
-
-
|
|
409
|
-
and the
|
|
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.
|
data/lib/strongroom/client.rb
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
185
|
-
unless
|
|
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 =
|
|
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)
|
data/lib/strongroom/errors.rb
CHANGED
|
@@ -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|
|
data/lib/strongroom/session.rb
CHANGED
|
@@ -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
|
data/lib/strongroom/token.rb
CHANGED
|
@@ -3,24 +3,29 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
|
|
5
5
|
module Strongroom
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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(
|
|
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
|
-
|
|
24
|
+
refuse
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def reveal = @wire
|
|
23
|
-
def inspect = "
|
|
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, "
|
|
38
|
+
raise TypeError, "credentials must not be deserialized"
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def _dump(_depth)
|
|
37
|
-
raise TypeError, "
|
|
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
|