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