kiosk-server 0.5.20 → 0.6.28
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 +196 -0
- data/README.md +192 -138
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +21 -65
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +20 -0
- data/app/models/kiosk/settlement.rb +1 -3
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -69
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +32 -62
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +6 -6
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +154 -0
- data/lib/kiosk/server/client_registration.rb +98 -0
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +64 -75
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +126 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +16 -13
- data/lib/kiosk/server/errors.rb +28 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +38 -10
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +17 -76
- data/lib/kiosk/server/executor.rb +86 -105
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +28 -29
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +22 -17
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +31 -0
- data/lib/kiosk/server/oauth_error.rb +33 -0
- data/lib/kiosk/server/open_api.rb +31 -29
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +47 -78
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +30 -0
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +3 -3
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +9 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +369 -58
- data/lib/kiosk/server/schemas/binding.schema.json +24 -59
- data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
- data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +4 -4
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +272 -205
- data/lib/kiosk/server/wire_rendering.rb +2 -2
- data/lib/kiosk/server.rb +8 -2
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +34 -24
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
- data/lib/kiosk/server/schema_definitions.rb +0 -351
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -8,7 +8,7 @@ module Kiosk
|
|
|
8
8
|
# cannot each build their own store (a double-spent proof of work).
|
|
9
9
|
LAZY_STORE_MUTEX = Mutex.new
|
|
10
10
|
|
|
11
|
-
# The discovery settings: where the
|
|
11
|
+
# The discovery settings: where the protocol is mounted and what it advertises.
|
|
12
12
|
module Discovery
|
|
13
13
|
# Default `/kiosk`. Discovery advertises `endpoint = origin + mount_path`.
|
|
14
14
|
attr_writer :mount_path
|
|
@@ -30,13 +30,18 @@ module Kiosk
|
|
|
30
30
|
@owner ||= {}
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# The skill
|
|
34
|
-
#
|
|
33
|
+
# The skill cut of this kiosk-server release. Each release gives the cut
|
|
34
|
+
# of its own version. Do not set it.
|
|
35
35
|
attr_writer :skill_url
|
|
36
36
|
|
|
37
37
|
def skill_url
|
|
38
|
-
@skill_url ||= 'https://kiosk.tech/skill-v0.
|
|
38
|
+
@skill_url ||= 'https://kiosk.tech/skill-v0.6.28.md'
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# The SHA-256 of the file at `skill_url`, as hex. Default nil: discovery
|
|
42
|
+
# then advertises no `skill` block. Make it with
|
|
43
|
+
# `curl -fsS <skill_url> | shasum -a 256`. Kiosk::TestHelpers::SkillPin
|
|
44
|
+
# fails when an update of kiosk-server changes `skill_url`.
|
|
40
45
|
attr_accessor :skill_sha256
|
|
41
46
|
|
|
42
47
|
private
|
|
@@ -57,7 +62,7 @@ module Kiosk
|
|
|
57
62
|
|
|
58
63
|
# The proof-of-work settings: registration proofs and the reputation challenge.
|
|
59
64
|
module ProofOfWork
|
|
60
|
-
# Equihash proofs asked at `POST /
|
|
65
|
+
# Equihash proofs asked at `POST /oauth/register`, at the link-code claim and
|
|
61
66
|
# at the first token poll of each device code, at the gem's default params.
|
|
62
67
|
# Default 0 (open). Above 0 needs `pow_secret` and `kiosk-pow-equihash`.
|
|
63
68
|
attr_writer :registration_pow_count
|
|
@@ -107,45 +112,15 @@ module Kiosk
|
|
|
107
112
|
def kyc_claims
|
|
108
113
|
@kyc_claims || []
|
|
109
114
|
end
|
|
110
|
-
|
|
111
|
-
# Must match the `iss` of submitted KYC attestations.
|
|
112
|
-
attr_writer :kyc_issuer
|
|
113
|
-
|
|
114
|
-
def kyc_issuer
|
|
115
|
-
@kyc_issuer
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# The `aud` a KYC attestation must carry. Default: the origin being served.
|
|
119
|
-
attr_writer :kyc_audience
|
|
120
|
-
|
|
121
|
-
def kyc_audience
|
|
122
|
-
@kyc_audience || Kiosk.current_issuer
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# The KYC provider's RSA key: an OpenSSL::PKey or a PEM string.
|
|
126
|
-
def kyc_public_key
|
|
127
|
-
@kyc_public_key
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def kyc_public_key=(value)
|
|
131
|
-
@kyc_public_key = case value
|
|
132
|
-
when OpenSSL::PKey::PKey then value
|
|
133
|
-
when String then OpenSSL::PKey::RSA.new(value)
|
|
134
|
-
when nil then nil
|
|
135
|
-
else
|
|
136
|
-
raise ArgumentError,
|
|
137
|
-
"kyc_public_key must be an OpenSSL::PKey or PEM string, got #{value.class}"
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
115
|
end
|
|
141
116
|
|
|
142
117
|
# The database settings: where Kiosk's tables live and how a request reaches them.
|
|
143
118
|
module Database
|
|
144
|
-
# Postgres schema
|
|
119
|
+
# The Postgres schema of Kiosk's `kiosk_*` tables. Default 'public'.
|
|
145
120
|
attr_writer :schema
|
|
146
121
|
|
|
147
122
|
def schema
|
|
148
|
-
@schema ||= '
|
|
123
|
+
@schema ||= 'public'
|
|
149
124
|
end
|
|
150
125
|
|
|
151
126
|
# Prefix of the session GUCs a request sets, e.g. `app.user_id`. Default 'app'.
|
|
@@ -169,27 +144,50 @@ module Kiosk
|
|
|
169
144
|
include Kyc
|
|
170
145
|
include Database
|
|
171
146
|
|
|
172
|
-
# Required. A SigningKey or
|
|
147
|
+
# Required. A SigningKey or the PEM string of an Ed25519 key.
|
|
173
148
|
def signing_key
|
|
174
149
|
@signing_key || raise(Errors::ConfigurationError,
|
|
175
|
-
|
|
150
|
+
"c.signing_key is not set. #{SigningKey::HINT.capitalize}.")
|
|
176
151
|
end
|
|
177
152
|
|
|
178
153
|
def signing_key=(value)
|
|
179
|
-
@signing_key =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
154
|
+
@signing_key = signing_key_from(value, 'signing_key')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Optional. The key that signed before {#signing_key}: the JWKS still
|
|
158
|
+
# publishes it, and a token or checkout it signed still verifies. A public
|
|
159
|
+
# PEM is enough. Keep it for at least one access-token lifetime, and while
|
|
160
|
+
# a checkout it signed can still be paid.
|
|
161
|
+
attr_reader :previous_signing_key
|
|
162
|
+
|
|
163
|
+
def previous_signing_key=(value)
|
|
164
|
+
@previous_signing_key = signing_key_from(value, 'previous_signing_key')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The keys the JWKS publishes and verification accepts, the current one first.
|
|
168
|
+
def signing_keys = [signing_key, previous_signing_key].compact
|
|
169
|
+
|
|
170
|
+
# The JOSE algorithms an AI assistant can sign with. Default `["EdDSA"]`:
|
|
171
|
+
# Ed25519 keys only. `%w[EdDSA ES256]` also accepts P-256 keys.
|
|
172
|
+
def assistant_algorithms
|
|
173
|
+
@assistant_algorithms || [Eddsa::NAME]
|
|
184
174
|
end
|
|
185
175
|
|
|
186
|
-
|
|
176
|
+
def assistant_algorithms=(value)
|
|
177
|
+
algorithms = Array(value).map(&:to_s)
|
|
178
|
+
valid = [[Eddsa::NAME], [Eddsa::NAME, 'ES256']].include?(algorithms)
|
|
179
|
+
raise Errors::ConfigurationError, "c.assistant_algorithms is %w[EdDSA] or %w[EdDSA ES256], got #{algorithms.inspect}" unless valid
|
|
180
|
+
|
|
181
|
+
@assistant_algorithms = algorithms.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The role every registered client is pinned to, and the default a
|
|
187
185
|
# binding falls back to. Required, and one of {#roles}, when {#roles} is set.
|
|
188
186
|
# Kiosk.configure { |c| c.roles = %i[customer]; c.registration_role = :customer }
|
|
189
187
|
attr_accessor :registration_role
|
|
190
188
|
|
|
191
|
-
# Optional `->(
|
|
192
|
-
#
|
|
189
|
+
# Optional `->(public_key_pem) { record_id }` creating the account behind a
|
|
190
|
+
# registered client. Unset: `user_model.constantize.create!`.
|
|
193
191
|
attr_accessor :assistant_creation
|
|
194
192
|
|
|
195
193
|
# Account-binding ceremony state. Default: the shared database store.
|
|
@@ -224,7 +222,7 @@ module Kiosk
|
|
|
224
222
|
true
|
|
225
223
|
end
|
|
226
224
|
|
|
227
|
-
# Called as `(agent:, from:, to:)` when an assistant's
|
|
225
|
+
# Called as `(agent:, from:, to:)` when an assistant's client moves from one
|
|
228
226
|
# account to another, inside the rebind transaction: the place to move
|
|
229
227
|
# the operator's own rows. `from` and `to` are `user_model` records.
|
|
230
228
|
attr_accessor :assistant_claimed
|
|
@@ -233,18 +231,16 @@ module Kiosk
|
|
|
233
231
|
# `account`, a `user_model` record, and its tokens are revoked.
|
|
234
232
|
attr_accessor :assistant_unlinked
|
|
235
233
|
|
|
236
|
-
# Optional `(agent_id:) → { amount_cents:, currency: } | nil
|
|
237
|
-
# before capture
|
|
234
|
+
# Optional `(agent_id:) → { amount_cents:, currency: } | nil`: the most
|
|
235
|
+
# one `pay` may charge, checked before capture. 0 blocks payments, and so
|
|
236
|
+
# does a checkout in another currency.
|
|
238
237
|
# {Kiosk::Server::ColumnSpendingCap} reads
|
|
239
238
|
# `agents.spending_cap_cents`, the column edited by the manage-assistants
|
|
240
239
|
# page, and `agents.spending_cap_currency`.
|
|
241
240
|
attr_accessor :spending_cap
|
|
242
241
|
|
|
243
|
-
#
|
|
244
|
-
attr_accessor :
|
|
245
|
-
|
|
246
|
-
# `call(id, lines) → cents | String`: the payable row's price, or why the cart is refused.
|
|
247
|
-
attr_accessor :cart_price_checker
|
|
242
|
+
# `call(id, lines) → cents | String`: the payable row's price, or why the checkout is refused.
|
|
243
|
+
attr_accessor :checkout_price_checker
|
|
248
244
|
|
|
249
245
|
# Optional `call(id)`, run once the settlement that paid row `id` is recorded.
|
|
250
246
|
attr_accessor :after_payment
|
|
@@ -282,7 +278,8 @@ module Kiosk
|
|
|
282
278
|
@audit_sink = value
|
|
283
279
|
end
|
|
284
280
|
|
|
285
|
-
# Spent
|
|
281
|
+
# Spent ids, so a proof-of-work proof or a client assertion is accepted once.
|
|
282
|
+
# Default: the shared database table.
|
|
286
283
|
attr_writer :pow_spent_store
|
|
287
284
|
|
|
288
285
|
def pow_spent_store
|
|
@@ -290,31 +287,13 @@ module Kiosk
|
|
|
290
287
|
LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
|
|
291
288
|
end
|
|
292
289
|
|
|
293
|
-
#
|
|
294
|
-
attr_writer :auth_challenge_store
|
|
295
|
-
|
|
296
|
-
def auth_challenge_store
|
|
297
|
-
@auth_challenge_store ||
|
|
298
|
-
LAZY_STORE_MUTEX.synchronize do
|
|
299
|
-
@auth_challenge_store ||= Kiosk::Server::AuthChallengeStores::ActiveRecord.new
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
|
|
290
|
+
# Seconds an access token lives. Default 3600.
|
|
304
291
|
attr_writer :access_token_ttl
|
|
305
292
|
|
|
306
293
|
def access_token_ttl
|
|
307
294
|
@access_token_ttl ||= JwtIssuer::DEFAULT_EXPIRES_IN
|
|
308
295
|
end
|
|
309
296
|
|
|
310
|
-
# Seconds between `GET /auth/challenge` and the signed POST. Default covers
|
|
311
|
-
# the auth toll's proof-of-work window plus 60s.
|
|
312
|
-
attr_writer :auth_challenge_ttl
|
|
313
|
-
|
|
314
|
-
def auth_challenge_ttl
|
|
315
|
-
@auth_challenge_ttl ||= (pow_ttl * [registration_pow_count.to_i, 1].max) + 60
|
|
316
|
-
end
|
|
317
|
-
|
|
318
297
|
# Revocation watermarks, checked on every access token. Default: the
|
|
319
298
|
# shared database table, so a revoke or an unlink holds in every process.
|
|
320
299
|
attr_writer :revocation_store
|
|
@@ -323,6 +302,16 @@ module Kiosk
|
|
|
323
302
|
@revocation_store ||
|
|
324
303
|
LAZY_STORE_MUTEX.synchronize { @revocation_store ||= Kiosk::Server::RevocationStores::ActiveRecord.new }
|
|
325
304
|
end
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
def signing_key_from(value, name)
|
|
309
|
+
case value
|
|
310
|
+
when Kiosk::Server::SigningKey, nil then value
|
|
311
|
+
when String then Kiosk::Server::SigningKey.from_pem(value)
|
|
312
|
+
else raise ArgumentError, "#{name} must be a SigningKey or PEM string, got #{value.class}"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
326
315
|
end
|
|
327
316
|
end
|
|
328
317
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The
|
|
5
|
+
# The protocol request being dispatched, visible to its handler for the duration
|
|
6
6
|
# of one block. Fiber-local: a handler that hands work to another thread must
|
|
7
7
|
# pass what it needs.
|
|
8
8
|
module CurrentRequest
|
|
@@ -5,7 +5,7 @@ require 'digest'
|
|
|
5
5
|
|
|
6
6
|
module Kiosk
|
|
7
7
|
module Server
|
|
8
|
-
# One account-binding request on the RFC 8628 device-grant
|
|
8
|
+
# One account-binding request on the RFC 8628 device-grant endpoints: a `:claim`
|
|
9
9
|
# the agent opens and a human approves, or a `:link` a signed-in human
|
|
10
10
|
# creates already approved. Only SHA-256 digests of its two codes are kept.
|
|
11
11
|
#
|
|
@@ -147,7 +147,7 @@ module Kiosk
|
|
|
147
147
|
private
|
|
148
148
|
|
|
149
149
|
def connection = ::ActiveRecord::Base.lease_connection
|
|
150
|
-
def table = %("#{Kiosk.configuration.schema}".
|
|
150
|
+
def table = %("#{Kiosk.configuration.schema}".kiosk_device_authorizations)
|
|
151
151
|
|
|
152
152
|
def insert_sql
|
|
153
153
|
placeholders = (1..COLUMNS.size).map { |i| "$#{i}" }.join(', ')
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The claim ceremony on the RFC 8628 device-grant
|
|
6
|
-
# to an existing human account once the human approves.
|
|
5
|
+
# The claim ceremony on the RFC 8628 device-grant endpoints: binds a registered
|
|
6
|
+
# client to an existing human account once the human approves.
|
|
7
7
|
#
|
|
8
|
-
# .start
|
|
9
|
-
# .
|
|
8
|
+
# .start — POST /oauth/device_authorization
|
|
9
|
+
# .pay_toll! — POST /oauth/token (grant_type=device_code), before the client authenticates
|
|
10
|
+
# .exchange — POST /oauth/token (grant_type=device_code), after it authenticates
|
|
10
11
|
module DeviceCodeGrant
|
|
11
|
-
GRANT_TYPE =
|
|
12
|
+
GRANT_TYPE = Client::DEVICE_CODE
|
|
12
13
|
|
|
13
14
|
# Seconds; a faster poll gets `slow_down` (RFC 8628 §3.5).
|
|
14
15
|
DEFAULT_POLL_INTERVAL = 5
|
|
@@ -18,9 +19,6 @@ module Kiosk
|
|
|
18
19
|
@poll_registry = {}
|
|
19
20
|
@poll_mutex = Mutex.new
|
|
20
21
|
|
|
21
|
-
PROOF_REQUIRED = 'signed proof-of-possession required ' \
|
|
22
|
-
'(GET /auth/challenge?public_key=… then sign {aud, nonce, jti})'
|
|
23
|
-
|
|
24
22
|
STATUS_FAILURES = {
|
|
25
23
|
pending: [:authorization_pending, 'the account holder has not yet approved'],
|
|
26
24
|
denied: [:access_denied, 'the account holder denied the request'],
|
|
@@ -30,14 +28,13 @@ module Kiosk
|
|
|
30
28
|
|
|
31
29
|
module_function
|
|
32
30
|
|
|
33
|
-
# No role parameter on purpose: the
|
|
34
|
-
def start(
|
|
35
|
-
public_key_pem:,
|
|
31
|
+
# No role parameter on purpose: the client takes the approving human's role.
|
|
32
|
+
def start(client:,
|
|
36
33
|
store: Kiosk.configuration.device_authorization_store,
|
|
37
34
|
expires_in: DeviceAuthorization::DEFAULT_EXPIRES_IN,
|
|
38
35
|
now: Time.current)
|
|
39
36
|
plain_device_code, plain_user_code, da = DeviceAuthorization.generate(
|
|
40
|
-
client_id:
|
|
37
|
+
client_id: client.id, kind: :claim, public_key_pem: client.public_key_pem,
|
|
41
38
|
expires_in: expires_in, now: now
|
|
42
39
|
)
|
|
43
40
|
store.create(da)
|
|
@@ -45,26 +42,30 @@ module Kiosk
|
|
|
45
42
|
expires_in: expires_in, interval: DEFAULT_POLL_INTERVAL, da: da }
|
|
46
43
|
end
|
|
47
44
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
# The first poll of each device code value is tolled; later polls
|
|
46
|
+
# of that value are free until any code could have expired. The store is
|
|
47
|
+
# not read first, so an unknown code costs the same work as a live one.
|
|
48
|
+
def pay_toll!(device_code, pow, now: Time.current)
|
|
49
|
+
config = Kiosk.configuration
|
|
50
|
+
paid = "device_code:#{DeviceAuthorization.hash_device_code(device_code)}"
|
|
51
|
+
return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
|
|
52
|
+
|
|
53
|
+
AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
|
|
54
|
+
config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Client] the client under the approving human's account
|
|
58
|
+
# @raise [OauthError] with an RFC 8628 §3.5 error code
|
|
59
|
+
def exchange(device_code:, client:,
|
|
53
60
|
store: Kiosk.configuration.device_authorization_store,
|
|
54
61
|
interval: DEFAULT_POLL_INTERVAL,
|
|
55
62
|
now: Time.current)
|
|
56
|
-
return failure(:invalid_request, 'device_code parameter required') if device_code.to_s.empty?
|
|
57
|
-
|
|
58
63
|
hash = DeviceAuthorization.hash_device_code(device_code)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return failure(:invalid_grant, 'unknown device_code') if da.nil?
|
|
62
|
-
return failure(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
|
|
63
|
-
|
|
64
|
-
da = expire_if_due(da, store, now)
|
|
65
|
-
return bind_and_mint(authorization: da, signed: signed, store: store, now: now) if da.approved?
|
|
64
|
+
da = authorization_of(client, store.lookup_by_device_code_hash(hash))
|
|
65
|
+
fail!(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
consume!(expire_if_due(da, store, now), store, now)
|
|
68
|
+
AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def reset_poll_registry!
|
|
@@ -74,16 +75,11 @@ module Kiosk
|
|
|
74
75
|
class << self
|
|
75
76
|
private
|
|
76
77
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def pay_toll_once!(device_code, hash, pow, now)
|
|
81
|
-
config = Kiosk.configuration
|
|
82
|
-
paid = "device_code:#{hash}"
|
|
83
|
-
return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
|
|
78
|
+
# A claim row that this client opened; a row of another client is unknown to it.
|
|
79
|
+
def authorization_of(client, authorization)
|
|
80
|
+
return authorization if authorization&.claim? && authorization.client_id == client.id
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
|
|
82
|
+
fail!(:invalid_grant, 'unknown device_code')
|
|
87
83
|
end
|
|
88
84
|
|
|
89
85
|
def expire_if_due(authorization, store, now)
|
|
@@ -93,41 +89,14 @@ module Kiosk
|
|
|
93
89
|
store.update(authorization.expire)
|
|
94
90
|
end
|
|
95
91
|
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return refusal if refusal
|
|
101
|
-
# Atomic, so two concurrent polls mint one token.
|
|
102
|
-
return failure(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
|
|
103
|
-
|
|
104
|
-
token_response(authorization)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def possession_refusal(public_key_pem, signed)
|
|
108
|
-
return failure(:invalid_client, PROOF_REQUIRED) if signed.nil? || signed.to_s.empty?
|
|
109
|
-
return failure(:invalid_client, 'no public key bound to this authorization') if public_key_pem.blank?
|
|
110
|
-
|
|
111
|
-
payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
|
|
112
|
-
AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
|
|
113
|
-
nil
|
|
114
|
-
rescue Errors::Base => e
|
|
115
|
-
failure(:invalid_client, e.message)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def token_response(authorization)
|
|
119
|
-
role = authorization.requested_role
|
|
120
|
-
result = AccountBinding.bind!(public_key_pem: authorization.public_key_pem,
|
|
121
|
-
user_id: authorization.user_id, requested_role: role)
|
|
122
|
-
response = { ok: true, access_token: result[:access_token], token_type: 'Bearer',
|
|
123
|
-
expires_in: Kiosk.configuration.access_token_ttl }
|
|
124
|
-
# RFC 6749 §5.1: the granted scope is the approving human's role.
|
|
125
|
-
response[:scope] = role if role
|
|
126
|
-
response
|
|
92
|
+
# Atomic, so two concurrent polls bind once.
|
|
93
|
+
def consume!(authorization, store, now)
|
|
94
|
+
fail!(*STATUS_FAILURES.fetch(authorization.status)) unless authorization.approved?
|
|
95
|
+
fail!(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
|
|
127
96
|
end
|
|
128
97
|
|
|
129
|
-
def
|
|
130
|
-
|
|
98
|
+
def fail!(error, description)
|
|
99
|
+
raise OauthError.new(error, description)
|
|
131
100
|
end
|
|
132
101
|
|
|
133
102
|
# Per process; a multi-process origin rate-limits at its edge.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'jwt'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module Kiosk
|
|
9
|
+
module Server
|
|
10
|
+
# RFC 9449 (spec Section 5.6): a token request, and a request that carries
|
|
11
|
+
# an access token, come with a proof that the registered key signed for
|
|
12
|
+
# this request. The proof's iat window is the replay bound; no nonce is
|
|
13
|
+
# issued and no jti is kept.
|
|
14
|
+
module Dpop
|
|
15
|
+
SCHEME = 'DPoP'
|
|
16
|
+
TYPE = 'dpop+jwt'
|
|
17
|
+
WINDOW = 60
|
|
18
|
+
CLAIMS = %w[jti htm htu iat].freeze
|
|
19
|
+
|
|
20
|
+
# A proof that does not hold.
|
|
21
|
+
class Invalid < StandardError; end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The `WWW-Authenticate` value of a 401 on a request that needs a token.
|
|
26
|
+
# `resource_metadata` names the protected-resource metadata (RFC 9728
|
|
27
|
+
# Section 5.1).
|
|
28
|
+
def challenge
|
|
29
|
+
%(#{SCHEME} algs="#{AssistantKey.algorithms.join(' ')}", ) +
|
|
30
|
+
%(resource_metadata="#{Kiosk.current_issuer}/.well-known/oauth-protected-resource")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hint
|
|
34
|
+
'send `Authorization: DPoP <access_token>` and one `DPoP` header: a JWS with typ dpop+jwt, ' \
|
|
35
|
+
"alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered key, signed by " \
|
|
36
|
+
'that key, whose payload carries jti, htm (the method), htu (the URL with no query), iat (now) ' \
|
|
37
|
+
'and ath (base64url SHA-256 of the access token)'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def token_request_hint
|
|
41
|
+
'send one `DPoP` header: a JWS with typ dpop+jwt, ' \
|
|
42
|
+
"alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered " \
|
|
43
|
+
'key, signed by that key, whose payload carries jti, htm POST, htu (the token URL) ' \
|
|
44
|
+
'and iat (now)'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The access token of an `Authorization: DPoP <token>` header, else nil.
|
|
48
|
+
def token(authorization)
|
|
49
|
+
authorization.to_s[/\A#{SCHEME}\s+(\S+)\z/io, 1]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Raises Errors::AccessTokenRefused unless `proof` is the key `jkt`'s proof
|
|
53
|
+
# for this method, URL and token, signed within {WINDOW} seconds of now.
|
|
54
|
+
def verify!(proof, token:, jkt:, method:, url:)
|
|
55
|
+
raise Invalid, 'the access token is bound to no key' if jkt.to_s.empty?
|
|
56
|
+
|
|
57
|
+
claims = check!(proof, jkt:, method:, url:, key: 'the key the token is bound to')
|
|
58
|
+
raise Invalid, 'the DPoP proof ath is not the hash of this token' unless claims['ath'] == hash_of(token)
|
|
59
|
+
rescue Invalid => e
|
|
60
|
+
raise Errors::AccessTokenRefused, e.message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raises OauthError `invalid_dpop_proof` unless `proof` is the proof of
|
|
64
|
+
# the registered key `jkt` for this token request. It carries no `ath`:
|
|
65
|
+
# there is no token yet.
|
|
66
|
+
def verify_token_request!(proof, jkt:, url:)
|
|
67
|
+
check!(proof, jkt:, method: 'POST', url:, key: 'your registered key')
|
|
68
|
+
rescue Invalid => e
|
|
69
|
+
raise OauthError.new(:invalid_dpop_proof, "#{e.message}: #{token_request_hint}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The base64url SHA-256 of the token: the `ath` a proof carries.
|
|
73
|
+
def hash_of(token) = Base64.urlsafe_encode64(Digest::SHA256.digest(token.to_s), padding: false)
|
|
74
|
+
|
|
75
|
+
# `key` names the key `jkt` is the thumbprint of.
|
|
76
|
+
def check!(proof, jkt:, method:, url:, key:)
|
|
77
|
+
raise Invalid, 'one DPoP header is required' if proof.to_s.empty? || proof.include?(',')
|
|
78
|
+
|
|
79
|
+
signer, claims = decode(proof)
|
|
80
|
+
raise Invalid, "the DPoP proof key is not #{key}" unless AssistantKey.thumbprint(signer) == jkt
|
|
81
|
+
|
|
82
|
+
check_request!(claims, method, url)
|
|
83
|
+
claims
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def check_request!(claims, method, url)
|
|
87
|
+
raise Invalid, 'the DPoP proof htm is not this request method' unless claims['htm'] == method
|
|
88
|
+
raise Invalid, 'the DPoP proof htu is not this request URL' unless same_url?(claims['htu'], url)
|
|
89
|
+
raise Invalid, 'the DPoP proof iat is not within 60 seconds of now' unless (claims['iat'].to_i - Time.now.to_i).abs <= WINDOW
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The key is the proof's own `jwk`, and its type names the one algorithm.
|
|
93
|
+
def decode(proof)
|
|
94
|
+
key = header_key(::JWT.decode(proof, nil, false).last)
|
|
95
|
+
algorithm = AssistantKey.algorithm(key)
|
|
96
|
+
claims, = ::JWT.decode(proof, key, true, algorithms: [algorithm], required_claims: CLAIMS)
|
|
97
|
+
[key, claims]
|
|
98
|
+
rescue ::JWT::IncorrectAlgorithm
|
|
99
|
+
raise Invalid, "the DPoP proof alg is not #{algorithm}, the algorithm of its jwk"
|
|
100
|
+
rescue ::JWT::DecodeError
|
|
101
|
+
raise Invalid, 'the DPoP proof is not a JWS that its jwk signed, with the claims of RFC 9449'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def header_key(header)
|
|
105
|
+
raise Invalid, 'the DPoP proof typ is not dpop+jwt' unless header['typ'] == TYPE
|
|
106
|
+
|
|
107
|
+
jwk = header['jwk']
|
|
108
|
+
raise Invalid, 'the DPoP proof carries no public jwk' unless jwk.is_a?(Hash) && !jwk.keys.intersect?(AssistantKey::PRIVATE_MEMBERS)
|
|
109
|
+
|
|
110
|
+
AssistantKey.from_jwk(jwk)
|
|
111
|
+
rescue Errors::BadRequest => e
|
|
112
|
+
raise Invalid, "the DPoP proof jwk is refused: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Scheme and host without case, a default port dropped (RFC 9449 Section 4.3).
|
|
116
|
+
def same_url?(htu, url)
|
|
117
|
+
Kiosk::Configuration.normalize_origin(htu) == Kiosk::Configuration.normalize_origin(url) &&
|
|
118
|
+
URI.parse(htu.to_s).path == URI.parse(url).path
|
|
119
|
+
rescue URI::InvalidURIError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_class_method :check!, :check_request!, :decode, :header_key, :same_url?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require_relative 'jwk'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module Server
|
|
9
|
+
# The JOSE algorithm EdDSA (RFC 8037) over the Ed25519 keys of OpenSSL.
|
|
10
|
+
# Loading this file registers `EdDSA` with ruby-jwt, so `JWT.encode` and
|
|
11
|
+
# `JWT.decode` accept the name.
|
|
12
|
+
class Eddsa
|
|
13
|
+
include ::JWT::JWA::SigningAlgorithm
|
|
14
|
+
|
|
15
|
+
NAME = 'EdDSA'
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@alg = NAME
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sign(data:, signing_key:)
|
|
22
|
+
raise_sign_error!("an EdDSA key is an Ed25519 key, got #{signing_key.class}") unless Jwk.ed25519?(signing_key)
|
|
23
|
+
|
|
24
|
+
signing_key.sign(nil, data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def verify(data:, signature:, verification_key:)
|
|
28
|
+
raise_verify_error!("an EdDSA key is an Ed25519 key, got #{verification_key.class}") unless Jwk.ed25519?(verification_key)
|
|
29
|
+
|
|
30
|
+
verification_key.verify(nil, signature, data)
|
|
31
|
+
rescue OpenSSL::PKey::PKeyError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
register_algorithm(new)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|