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
|
@@ -4,7 +4,7 @@ module Kiosk
|
|
|
4
4
|
module Server
|
|
5
5
|
# Runs one call for an identity inside a {SessionContext}; returns a {Result} or raises {Errors::Base}.
|
|
6
6
|
class Executor
|
|
7
|
-
# The coarse kinds of call shared by the toll, the policy and this dispatcher; not
|
|
7
|
+
# The coarse kinds of call shared by the toll, the policy and this dispatcher; not HTTP paths.
|
|
8
8
|
VERBS = %i[query run pay].freeze
|
|
9
9
|
|
|
10
10
|
# Kinds with an irreversible external effect (a PSP capture) that manage their own transactions.
|
|
@@ -121,7 +121,7 @@ module Kiosk
|
|
|
121
121
|
result
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
# The handler's message goes to the operator's log, never onto the
|
|
124
|
+
# The handler's message goes to the operator's log, never onto the protocol.
|
|
125
125
|
def report_handler_failure(kind, name, error)
|
|
126
126
|
FailureLog.report("#{kind} #{name.inspect} raised #{error.class}", error)
|
|
127
127
|
end
|
|
@@ -134,22 +134,22 @@ module Kiosk
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
# The `pay` verb: settles an
|
|
138
|
-
# between phases 1 and 3 (§11.6).
|
|
137
|
+
# The `pay` verb: settles an operator-signed checkout through two closed AP2
|
|
138
|
+
# mandates. The PSP capture runs outside any transaction, between phases 1 and 3 (§11.6).
|
|
139
139
|
module Pay
|
|
140
140
|
RETRYABLE_HINT = 'the charge did not go through and no money moved. The human may need to ' \
|
|
141
|
-
'update the payment method (payment_setup). Then sign
|
|
141
|
+
'update the payment method (payment_setup). Then sign new mandates and pay again.'
|
|
142
142
|
UNKNOWN_HINT = 'the payment provider did not confirm the charge, so its status is unknown. ' \
|
|
143
|
-
'Send the SAME
|
|
144
|
-
'most once. Do not sign
|
|
145
|
-
CONFLICT_HINT = 'this operator will not charge
|
|
146
|
-
'or
|
|
147
|
-
"
|
|
148
|
-
'
|
|
149
|
-
|
|
150
|
-
# The verified mandates, the
|
|
143
|
+
'Send the SAME two mandates again, byte for byte: this operator charges one pair ' \
|
|
144
|
+
'at most once. Do not sign new mandates.'
|
|
145
|
+
CONFLICT_HINT = 'this operator will not charge these mandates: an earlier call with them was ' \
|
|
146
|
+
'declined, or another pair for this checkout is settled or still in progress. ' \
|
|
147
|
+
"Before you sign new mandates, read the order's paid state through the operator's " \
|
|
148
|
+
'own query, and sign new ones only on a clear "not paid".'
|
|
149
|
+
|
|
150
|
+
# The verified mandates, the checkout row they were persisted under, and
|
|
151
151
|
# whether an earlier call already persisted them.
|
|
152
|
-
Chain = Struct.new(:
|
|
152
|
+
Chain = Struct.new(:checkout, :payment, :checkout_row, :repeat)
|
|
153
153
|
|
|
154
154
|
private
|
|
155
155
|
|
|
@@ -158,7 +158,7 @@ module Kiosk
|
|
|
158
158
|
# Whether this origin serves payments is answered before any argument is read.
|
|
159
159
|
provider = payment_provider!
|
|
160
160
|
raws = pay_mandates!(args)
|
|
161
|
-
# Refused before phase 1 so the
|
|
161
|
+
# Refused before phase 1, so the checkout is not held by a charge that cannot succeed.
|
|
162
162
|
raise Errors::PaymentSetupRequired if provider.respond_to?(:setup_required?) && provider.setup_required?(user_id: identity.user_id)
|
|
163
163
|
|
|
164
164
|
chain = persist_chain(raws)
|
|
@@ -177,20 +177,21 @@ module Kiosk
|
|
|
177
177
|
)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
-
# The
|
|
180
|
+
# The two signed mandates from the args, by part.
|
|
181
181
|
def pay_mandates!(args)
|
|
182
182
|
# Mandates are agent-signed, so a principal without an agent id cannot pay.
|
|
183
183
|
raise Errors::Forbidden, 'payment requires an agent identity (mandates are agent-signed)' if identity.agent_id.nil?
|
|
184
184
|
|
|
185
|
-
%i[
|
|
186
|
-
raw = args[:"#{part}
|
|
187
|
-
raise Errors::BadRequest, "args.#{part}
|
|
185
|
+
%i[checkout payment].to_h do |part|
|
|
186
|
+
raw = args[:"#{part}_mandate"]
|
|
187
|
+
raise Errors::BadRequest, "args.#{part}_mandate required" if raw.nil? || raw.to_s.empty?
|
|
188
188
|
|
|
189
189
|
[part, raw]
|
|
190
190
|
end
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
-
# Phase 1: verify and persist the
|
|
193
|
+
# Phase 1: verify and persist the mandates. A unique violation means they, or another
|
|
194
|
+
# live pair for the same checkout, were presented before.
|
|
194
195
|
def persist_chain(raws)
|
|
195
196
|
chain = Chain.new
|
|
196
197
|
verify_and_persist(chain, raws)
|
|
@@ -202,30 +203,24 @@ module Kiosk
|
|
|
202
203
|
|
|
203
204
|
def verify_and_persist(chain, raws)
|
|
204
205
|
SessionContext.open(connection: connection, identity: identity) do
|
|
205
|
-
|
|
206
|
-
# Before any persist or capture, so a refusal
|
|
207
|
-
enforce_spending_cap!(
|
|
208
|
-
chain.payment
|
|
209
|
-
chain.
|
|
206
|
+
chain.checkout = MandateVerifier.verify_checkout(sd_jwt: raws[:checkout], identity: identity)
|
|
207
|
+
# Before any persist or capture, so a refusal holds nothing.
|
|
208
|
+
enforce_spending_cap!(chain.checkout)
|
|
209
|
+
chain.payment = MandateVerifier.verify_payment(sd_jwt: raws[:payment], identity: identity, checkout: chain.checkout)
|
|
210
|
+
chain.checkout_row = persist_mandates(chain)
|
|
210
211
|
end
|
|
211
212
|
chain
|
|
212
213
|
end
|
|
213
214
|
|
|
214
|
-
|
|
215
|
-
chain.intent = MandateVerifier.verify_intent(raw_jws: raws[:intent], identity: identity)
|
|
216
|
-
chain.cart = MandateVerifier.verify_cart(raw_jws: raws[:cart], identity: identity, intent: chain.intent)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# The cart row id.
|
|
215
|
+
# The checkout row id.
|
|
220
216
|
def persist_mandates(chain)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
cart_row
|
|
217
|
+
checkout_row = persist_checkout_mandate(chain.checkout)
|
|
218
|
+
persist_payment_mandate(checkout_row_id: checkout_row, payment: chain.payment)
|
|
219
|
+
checkout_row
|
|
225
220
|
end
|
|
226
221
|
|
|
227
222
|
# Phase 2: the irreversible capture. A refusal under which no money moved
|
|
228
|
-
# declines the
|
|
223
|
+
# declines the checkout mandate, so a new pair can pay that checkout.
|
|
229
224
|
def capture(provider, chain)
|
|
230
225
|
charge(provider, chain)
|
|
231
226
|
rescue Kiosk::PaymentProviders::SetupRequired
|
|
@@ -239,139 +234,125 @@ module Kiosk
|
|
|
239
234
|
raise
|
|
240
235
|
end
|
|
241
236
|
|
|
242
|
-
# A repeated
|
|
237
|
+
# A repeated pair resumes its capture under the same idempotency key.
|
|
243
238
|
def charge(provider, chain)
|
|
244
239
|
method = chain.repeat ? :resume : :capture
|
|
245
|
-
provider.public_send(method, chain.
|
|
240
|
+
provider.public_send(method, chain.checkout, payment_method: chain.payment.payment_instrument)
|
|
246
241
|
end
|
|
247
242
|
|
|
248
243
|
# Phase 3: record the settlement, then tell the provider, so the
|
|
249
244
|
# operator's work after the payment reads the settlement. A concurrent
|
|
250
|
-
# call that settled the same
|
|
245
|
+
# call that settled the same checkout first answers with its settlement.
|
|
251
246
|
def settle(provider, chain, settled)
|
|
252
247
|
settlement_id = record_settlement(chain, settled)
|
|
253
|
-
provider.settled(chain.
|
|
254
|
-
settlement_result(
|
|
248
|
+
provider.settled(chain.checkout) if provider.respond_to?(:settled)
|
|
249
|
+
settlement_result(chain, settlement_id, settled, chain.checkout.currency)
|
|
255
250
|
rescue StandardError => e
|
|
256
251
|
row = unique_violation?(e) && recorded_chain(chain)
|
|
257
252
|
raise unless row && row['settlement_id']
|
|
258
253
|
|
|
259
|
-
stored_settlement(row)
|
|
254
|
+
stored_settlement(chain, row)
|
|
260
255
|
end
|
|
261
256
|
|
|
262
257
|
# The settlement row's id.
|
|
263
258
|
def record_settlement(chain, settled)
|
|
264
259
|
SessionContext.open(connection: connection, identity: identity) do
|
|
265
|
-
persist_settlement(
|
|
260
|
+
persist_settlement(checkout_row_id: chain.checkout_row, checkout: chain.checkout, settled: settled)
|
|
266
261
|
end
|
|
267
262
|
end
|
|
268
263
|
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
# The settlement, and the two receipts that name its mandates (§11.3).
|
|
265
|
+
# `settled`: `psp_reference`, `settled_amount_cents` and `network_confirmation_id`.
|
|
266
|
+
def settlement_result(chain, settlement_id, settled, currency)
|
|
267
|
+
psp_reference = settled[:psp_reference]
|
|
268
|
+
receipts = Receipts.issue(checkout: chain.checkout, payment: chain.payment, settlement_id:, psp_reference:,
|
|
269
|
+
network_confirmation_id: settled[:network_confirmation_id])
|
|
270
|
+
Result.new(kind: :value, payload: { settlement_id:, psp_reference:, settled_amount_cents: settled[:settled_amount_cents],
|
|
271
|
+
currency:, **receipts })
|
|
271
272
|
end
|
|
272
273
|
end
|
|
273
274
|
|
|
274
275
|
# The mandate and settlement rows. Every value is a bind parameter; only the
|
|
275
276
|
# operator-set schema name is interpolated.
|
|
276
277
|
module Ledger
|
|
277
|
-
#
|
|
278
|
+
# The byte-identical pair of the acting principal, with its settlement if any.
|
|
278
279
|
CHAIN_SQL = <<~SQL.squish
|
|
279
|
-
SELECT c.id AS
|
|
280
|
-
s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
JOIN %<schema>s.
|
|
284
|
-
LEFT JOIN %<schema>s.
|
|
285
|
-
WHERE c.user_id = $1 AND
|
|
286
|
-
AND c.
|
|
287
|
-
AND c.
|
|
288
|
-
AND
|
|
289
|
-
AND p.raw_jws = $5
|
|
280
|
+
SELECT c.id AS checkout_row, c.declined_at,
|
|
281
|
+
s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency,
|
|
282
|
+
s.network_confirmation_id
|
|
283
|
+
FROM %<schema>s.kiosk_checkout_mandates c
|
|
284
|
+
JOIN %<schema>s.kiosk_payment_mandates p ON p.checkout_mandate_id = c.id
|
|
285
|
+
LEFT JOIN %<schema>s.kiosk_settlements s ON s.checkout_mandate_id = c.id
|
|
286
|
+
WHERE c.user_id = $1 AND p.user_id = $1
|
|
287
|
+
AND c.reference = $2
|
|
288
|
+
AND c.sd_jwt = $3
|
|
289
|
+
AND p.sd_jwt = $4
|
|
290
290
|
LIMIT 1
|
|
291
291
|
SQL
|
|
292
292
|
|
|
293
|
-
# The signed id goes to `mandate_id`; the row id is server-generated and returned.
|
|
294
|
-
INTENT_INSERT_SQL = <<~SQL.squish
|
|
295
|
-
INSERT INTO %<schema>s.intent_mandates
|
|
296
|
-
(mandate_id, user_id, agent_id, issuer, scope, cap_amount_cents,
|
|
297
|
-
currency, expires_at, created_at, raw_jws)
|
|
298
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), $9)
|
|
299
|
-
RETURNING id
|
|
300
|
-
SQL
|
|
301
|
-
|
|
302
293
|
# `line_items` arrives as JSON text; the cast stores a jsonb array rather than a string.
|
|
303
|
-
|
|
304
|
-
INSERT INTO %<schema>s.
|
|
305
|
-
(
|
|
306
|
-
total_amount_cents, currency, expires_at, created_at,
|
|
294
|
+
CHECKOUT_INSERT_SQL = <<~SQL.squish
|
|
295
|
+
INSERT INTO %<schema>s.kiosk_checkout_mandates
|
|
296
|
+
(reference, checkout_hash, user_id, agent_id, issuer, line_items,
|
|
297
|
+
total_amount_cents, currency, expires_at, created_at, sd_jwt)
|
|
307
298
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, now(), $10)
|
|
308
299
|
RETURNING id
|
|
309
300
|
SQL
|
|
310
301
|
|
|
311
302
|
PAYMENT_INSERT_SQL = <<~SQL.squish
|
|
312
|
-
INSERT INTO %<schema>s.
|
|
313
|
-
(
|
|
314
|
-
|
|
303
|
+
INSERT INTO %<schema>s.kiosk_payment_mandates
|
|
304
|
+
(reference, checkout_mandate_id, user_id, agent_id, issuer,
|
|
305
|
+
payment_instrument, amount_cents, currency, expires_at, created_at, sd_jwt)
|
|
315
306
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
|
|
316
307
|
RETURNING id
|
|
317
308
|
SQL
|
|
318
309
|
|
|
319
|
-
# Nobody signs a settlement
|
|
310
|
+
# Nobody signs a settlement; `UNIQUE (checkout_mandate_id)` keeps it single.
|
|
320
311
|
SETTLEMENT_INSERT_SQL = <<~SQL.squish
|
|
321
|
-
INSERT INTO %<schema>s.
|
|
322
|
-
(
|
|
323
|
-
settled_amount_cents, currency, settled_at)
|
|
324
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
312
|
+
INSERT INTO %<schema>s.kiosk_settlements
|
|
313
|
+
(checkout_mandate_id, user_id, agent_id, issuer, psp_reference,
|
|
314
|
+
settled_amount_cents, currency, network_confirmation_id, settled_at)
|
|
315
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
|
325
316
|
RETURNING id
|
|
326
317
|
SQL
|
|
327
318
|
|
|
328
319
|
DECLINE_SQL = <<~SQL.squish
|
|
329
|
-
UPDATE %<schema>s.
|
|
320
|
+
UPDATE %<schema>s.kiosk_checkout_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
|
|
330
321
|
SQL
|
|
331
322
|
|
|
332
323
|
private
|
|
333
324
|
|
|
334
325
|
def chain_record(chain)
|
|
335
326
|
connection.exec_query(ledger_sql(CHAIN_SQL), 'Kiosk chain lookup', [
|
|
336
|
-
identity.user_id, chain.
|
|
337
|
-
chain.payment.raw_jws
|
|
327
|
+
identity.user_id, chain.checkout.reference, chain.checkout.sd_jwt, chain.payment.sd_jwt
|
|
338
328
|
]).to_a.first
|
|
339
329
|
end
|
|
340
330
|
|
|
341
|
-
def
|
|
342
|
-
connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def persist_intent_mandate(intent)
|
|
346
|
-
insert_returning_id('Kiosk intent_mandate insert', INTENT_INSERT_SQL, [
|
|
347
|
-
intent.id, intent.user_id, intent.agent_id, intent.issuer, intent.scope,
|
|
348
|
-
intent.cap_amount_cents.to_i, intent.currency, intent.expires_at, intent.raw_jws
|
|
349
|
-
])
|
|
331
|
+
def decline_checkout(checkout_row)
|
|
332
|
+
connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk checkout decline', [checkout_row])
|
|
350
333
|
end
|
|
351
334
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
cart.expires_at, cart.raw_jws
|
|
335
|
+
def persist_checkout_mandate(checkout)
|
|
336
|
+
insert_returning_id('Kiosk checkout_mandate insert', CHECKOUT_INSERT_SQL, [
|
|
337
|
+
checkout.reference, checkout.checkout_hash, checkout.user_id, checkout.agent_id,
|
|
338
|
+
checkout.issuer, checkout.line_items.to_json, checkout.total_amount_cents,
|
|
339
|
+
checkout.currency, checkout.expires_at, checkout.sd_jwt
|
|
358
340
|
])
|
|
359
341
|
end
|
|
360
342
|
|
|
361
|
-
def persist_payment_mandate(
|
|
362
|
-
# No presented payment method means the on-file card funded the charge.
|
|
363
|
-
pm_db = payment.payment_method.to_s.empty? ? 'on_file' : payment.payment_method
|
|
343
|
+
def persist_payment_mandate(checkout_row_id:, payment:)
|
|
364
344
|
insert_returning_id('Kiosk payment_mandate insert', PAYMENT_INSERT_SQL, [
|
|
365
|
-
payment.
|
|
366
|
-
|
|
367
|
-
payment.
|
|
345
|
+
payment.reference, checkout_row_id, payment.user_id, payment.agent_id, payment.issuer,
|
|
346
|
+
payment.payment_instrument, payment.amount_cents, payment.currency, payment.expires_at,
|
|
347
|
+
payment.sd_jwt
|
|
368
348
|
])
|
|
369
349
|
end
|
|
370
350
|
|
|
371
|
-
def persist_settlement(
|
|
351
|
+
def persist_settlement(checkout_row_id:, checkout:, settled:)
|
|
372
352
|
insert_returning_id('Kiosk settlement insert', SETTLEMENT_INSERT_SQL, [
|
|
373
|
-
|
|
374
|
-
settled[:psp_reference], settled[:settled_amount_cents].to_i,
|
|
353
|
+
checkout_row_id, checkout.user_id, checkout.agent_id, checkout.issuer,
|
|
354
|
+
settled[:psp_reference], settled[:settled_amount_cents].to_i, checkout.currency,
|
|
355
|
+
settled[:network_confirmation_id]
|
|
375
356
|
])
|
|
376
357
|
end
|
|
377
358
|
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# Logs the exception whose message a refusal kept
|
|
5
|
+
# Logs the exception whose message a refusal kept out of the response: Rails' logger, else `warn`.
|
|
6
6
|
# A raising logger is swallowed so one failure does not become two.
|
|
7
7
|
module FailureLog
|
|
8
8
|
# Enough frames to reach the operator's own code without flooding the log.
|
|
9
9
|
BACKTRACE_FRAMES = 20
|
|
10
10
|
|
|
11
|
-
# One string to grep for refusals that kept their message off the
|
|
11
|
+
# One string to grep for refusals that kept their message off the protocol.
|
|
12
12
|
PREFIX = '[kiosk-server]'
|
|
13
13
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
# `summary` is the caller's own words, the half that also reaches the
|
|
16
|
+
# `summary` is the caller's own words, the half that also reaches the protocol.
|
|
17
17
|
def report(summary, error)
|
|
18
18
|
line = "#{PREFIX} #{summary}: #{error.message}\n " \
|
|
19
19
|
"#{Array(error.backtrace).first(BACKTRACE_FRAMES).join("\n ")}"
|
|
@@ -5,8 +5,8 @@ require 'action_dispatch'
|
|
|
5
5
|
|
|
6
6
|
module Kiosk
|
|
7
7
|
module Server
|
|
8
|
-
# Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a
|
|
9
|
-
# a method with no pending declaration stays off the
|
|
8
|
+
# Implementation behind `include Kiosk::Handler`. The macros above a `def` make it a verb;
|
|
9
|
+
# a method with no pending declaration stays off the protocol.
|
|
10
10
|
module HandlerMixin
|
|
11
11
|
KINDS = %i[action query].freeze
|
|
12
12
|
|
|
@@ -38,7 +38,7 @@ module Kiosk
|
|
|
38
38
|
base.extend(ClassMethods)
|
|
39
39
|
base.include(InstanceMethods)
|
|
40
40
|
|
|
41
|
-
# The
|
|
41
|
+
# The protocol authenticates by bearer token, never by cookie, so CSRF protection does not apply.
|
|
42
42
|
base.skip_forgery_protection if base.respond_to?(:skip_forgery_protection)
|
|
43
43
|
|
|
44
44
|
# Registered at include time, so an operator's own `rescue_from` declared later wins.
|
|
@@ -80,7 +80,7 @@ module Kiosk
|
|
|
80
80
|
|
|
81
81
|
def bad_topic_name(owner, name)
|
|
82
82
|
"#{owner} declared `topic #{name.inspect}`, which is not a legal Kiosk name. " \
|
|
83
|
-
"A topic name
|
|
83
|
+
"A topic name matches #{NAME_PATTERN.inspect}."
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def reserved_topic(owner, name)
|
|
@@ -300,12 +300,12 @@ module Kiosk
|
|
|
300
300
|
kiosk_declare(method_name, pending)
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
-
#
|
|
303
|
+
# Published name → declaration for the verbs on this class, one per name.
|
|
304
304
|
def kiosk_declarations
|
|
305
305
|
@kiosk_declarations ||= {}
|
|
306
306
|
end
|
|
307
307
|
|
|
308
|
-
#
|
|
308
|
+
# Published name → declaration for the topics on this class.
|
|
309
309
|
def kiosk_topic_declarations
|
|
310
310
|
@kiosk_topic_declarations ||= {}
|
|
311
311
|
end
|
|
@@ -361,7 +361,7 @@ module Kiosk
|
|
|
361
361
|
|
|
362
362
|
private
|
|
363
363
|
|
|
364
|
-
# The acting {Kiosk::Identity}; nil outside a
|
|
364
|
+
# The acting {Kiosk::Identity}; nil outside a protocol request.
|
|
365
365
|
def kiosk_identity = CurrentRequest.identity
|
|
366
366
|
|
|
367
367
|
# Differs from the method name only under `wire_name`.
|
|
@@ -376,8 +376,8 @@ module Kiosk
|
|
|
376
376
|
end
|
|
377
377
|
|
|
378
378
|
# A validation failure answers 400 with the errors' full messages. Any other raise Rails knows
|
|
379
|
-
# a status for maps to its lone
|
|
380
|
-
# never onto the
|
|
379
|
+
# a status for maps to its lone error code, its own message going to the operator's log and
|
|
380
|
+
# never onto the protocol; anything else is re-raised.
|
|
381
381
|
def kiosk_rescue_to_wire(exception)
|
|
382
382
|
raise exception if exception.is_a?(Kiosk::Server::Errors::Base)
|
|
383
383
|
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
load_handlers_dir
|
|
19
19
|
handlers.select! { |name| name.safe_constantize&.kiosk_register! }
|
|
20
20
|
PaymentSetup.register! if Kiosk.configuration.payment_provider
|
|
21
|
-
Kyc.register! if Kiosk.configuration.kyc_provider
|
|
21
|
+
Kyc.register! if Kiosk.configuration.kyc_provider&.opens_verifications?
|
|
22
22
|
refuse_cross_kind_collisions!
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -5,17 +5,17 @@ require 'json'
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
7
|
# How a controller that includes {Kiosk::Handler} answers a verb call.
|
|
8
|
-
# {Routing::VerbRoute} sends the
|
|
8
|
+
# {Routing::VerbRoute} sends the protocol request to the action that declares
|
|
9
9
|
# the verb. Around that action this module runs the gates of {VerbWire},
|
|
10
10
|
# the session and the transaction, and writes the action's render as the
|
|
11
|
-
#
|
|
11
|
+
# protocol answer.
|
|
12
12
|
module HandlerServing
|
|
13
13
|
include VerbWire
|
|
14
14
|
|
|
15
|
-
# The Rack env key where {Routing::VerbRoute} puts the
|
|
15
|
+
# The Rack env key where {Routing::VerbRoute} puts the published name it routed.
|
|
16
16
|
VERB_KEY = 'kiosk.verb'
|
|
17
17
|
|
|
18
|
-
# The one header of an action's render that reaches the
|
|
18
|
+
# The one header of an action's render that reaches the response: `Cache-Control`, on a 2xx (§3.7.4).
|
|
19
19
|
PROPAGATED_HEADERS = %w[Cache-Control].freeze
|
|
20
20
|
|
|
21
21
|
private
|
|
@@ -34,7 +34,7 @@ module Kiosk
|
|
|
34
34
|
kiosk_render_refusal(Errors.malformed_json(hint: BODY_HINT))
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# The verb's {Result}: the
|
|
37
|
+
# The verb's {Result}: the protocol's gates, then the action inside the session.
|
|
38
38
|
def kiosk_call(declaration, &)
|
|
39
39
|
kind = declaration[:kind]
|
|
40
40
|
name = @_kiosk_wire_name = declaration[:wire_name]
|
|
@@ -55,7 +55,7 @@ module Kiosk
|
|
|
55
55
|
Answer.new(kind, @_kiosk_wire_name).value(status, kiosk_read_body(body), @_kiosk_page)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# The action's `params` are the decoded arguments; the
|
|
58
|
+
# The action's `params` are the decoded arguments; the protocol answers JSON whatever the caller's
|
|
59
59
|
# `Accept`, so an action's template is its `.json` one.
|
|
60
60
|
def kiosk_wire_parameters!(args)
|
|
61
61
|
request.set_header('action_dispatch.request.request_parameters', args.dup)
|
|
@@ -89,7 +89,7 @@ module Kiosk
|
|
|
89
89
|
render_wire_error(error)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# The action's render was read; the
|
|
92
|
+
# The action's render was read; the protocol answer is written into a fresh response.
|
|
93
93
|
def kiosk_fresh_response!
|
|
94
94
|
set_response!(self.class.make_response!(request))
|
|
95
95
|
@_response_body = nil
|
|
@@ -98,7 +98,7 @@ module Kiosk
|
|
|
98
98
|
# A route drawn straight at a handler action, not through `kiosk do`, answers 404 `verb_not_found`.
|
|
99
99
|
def kiosk_refuse_unrouted
|
|
100
100
|
problem = Errors::VerbNotFound.new(
|
|
101
|
-
'Kiosk handlers are reachable through the Kiosk
|
|
101
|
+
'Kiosk handlers are reachable through the Kiosk HTTP API only',
|
|
102
102
|
hint: "call the verb's own route — " \
|
|
103
103
|
"GET #{Kiosk.configuration.mount_path}/<query-name> or " \
|
|
104
104
|
"POST #{Kiosk.configuration.mount_path}/<action-name>"
|
|
@@ -107,7 +107,7 @@ module Kiosk
|
|
|
107
107
|
render json: problem, status: :not_found, content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
# An action's render as the verb's answer: its payload, or the
|
|
110
|
+
# An action's render as the verb's answer: its payload, or the Kiosk error it means.
|
|
111
111
|
class Answer
|
|
112
112
|
def initialize(kind, wire_name)
|
|
113
113
|
@kind = kind
|
|
@@ -125,7 +125,7 @@ module Kiosk
|
|
|
125
125
|
|
|
126
126
|
private
|
|
127
127
|
|
|
128
|
-
# 2xx → the rendered JSON; otherwise the
|
|
128
|
+
# 2xx → the rendered JSON; otherwise the Kiosk error it means.
|
|
129
129
|
def decode(status, raw)
|
|
130
130
|
return parse_json(raw) if status >= 200 && status < 300
|
|
131
131
|
|
|
@@ -172,7 +172,7 @@ module Kiosk
|
|
|
172
172
|
raise Errors::ActionFailed.new(
|
|
173
173
|
"#{@kind} #{@wire_name.inspect} rendered a non-JSON body",
|
|
174
174
|
hint: 'a Kiosk handler answers with `render json:` — HTML, redirects and ' \
|
|
175
|
-
'`send_file` have no place
|
|
175
|
+
'`send_file` have no place in a Kiosk response'
|
|
176
176
|
)
|
|
177
177
|
end
|
|
178
178
|
|
data/lib/kiosk/server/headers.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The Kiosk response headers and the cache policy of a
|
|
5
|
+
# The Kiosk response headers and the cache policy of a Kiosk response.
|
|
6
6
|
module Headers
|
|
7
7
|
def self.add_to(headers, server_version: Kiosk::Server::VERSION)
|
|
8
8
|
headers[Kiosk::Protocol::HEADER_SERVER_VERSION] = server_version
|
|
@@ -18,7 +18,7 @@ module Kiosk
|
|
|
18
18
|
# §3.7.1: every one of these changes the answer and none is in the URL.
|
|
19
19
|
WIRE_VARY = %w[Authorization Kiosk-PoW Kiosk-Timezone].freeze
|
|
20
20
|
|
|
21
|
-
# Adds the
|
|
21
|
+
# Adds the protocol `Vary`; forces `no-store` on a 402; defaults to
|
|
22
22
|
# `private, no-store` and refuses a shared-cache directive (§3.7.3).
|
|
23
23
|
def self.add_cache_policy(headers, status:)
|
|
24
24
|
add_wire_vary(headers)
|
|
@@ -56,7 +56,7 @@ module Kiosk
|
|
|
56
56
|
'Sent `private, no-store` instead; send `private, max-age=N` to let the caller cache it.'
|
|
57
57
|
|
|
58
58
|
def self.refuse_shared_cache(value)
|
|
59
|
-
message = '[kiosk-server] refused a shared-cache policy on a
|
|
59
|
+
message = '[kiosk-server] refused a shared-cache policy on a Kiosk response: ' \
|
|
60
60
|
"Cache-Control: #{value.inspect}. #{SHARED_CACHE_RULE}"
|
|
61
61
|
logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
62
62
|
logger ? logger.warn(message) : warn(message)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The identity of a
|
|
6
|
-
# the configured agent IdP or the bundled
|
|
5
|
+
# The identity of a protocol or event-stream caller: the access token, read by
|
|
6
|
+
# the configured agent IdP or the bundled one. A cookie session
|
|
7
7
|
# reaches only the human-side pages and the binding endpoints.
|
|
8
8
|
module IdentityResolution
|
|
9
9
|
module_function
|
|
@@ -12,8 +12,17 @@ module Kiosk
|
|
|
12
12
|
Kiosk.configuration.agent_idp || AgentIdentityProviders::DefaultAgentIdp.new
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# The identity of the token, once the request's DPoP proof shows the key
|
|
16
|
+
# the token is bound to; nil with no valid token.
|
|
17
|
+
# @raise [Errors::AccessTokenRefused] a valid token without that proof
|
|
15
18
|
def resolve(request)
|
|
16
|
-
agent_idp.verify(request)
|
|
19
|
+
identity = agent_idp.verify(request)
|
|
20
|
+
return if identity.nil?
|
|
21
|
+
|
|
22
|
+
Dpop.verify!(request.headers['DPoP'],
|
|
23
|
+
token: Dpop.token(request.headers['Authorization']), jkt: identity.claims.dig(:cnf, :jkt),
|
|
24
|
+
method: request.request_method, url: "#{Kiosk.current_issuer}#{request.path}")
|
|
25
|
+
identity
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'jwt'
|
|
7
|
+
require 'openssl'
|
|
8
|
+
|
|
9
|
+
module Kiosk
|
|
10
|
+
module Server
|
|
11
|
+
# The public keys Kiosk signs and verifies with, as OpenSSL keys and as
|
|
12
|
+
# JWKs: Ed25519 (RFC 8037, `kty` OKP, algorithm EdDSA) and P-256 (RFC 7518,
|
|
13
|
+
# `kty` EC, algorithm ES256).
|
|
14
|
+
module Jwk
|
|
15
|
+
# The JOSE algorithm of each key type.
|
|
16
|
+
ALGORITHMS = { 'OKP' => 'EdDSA', 'EC' => 'ES256' }.freeze
|
|
17
|
+
|
|
18
|
+
# The curve of each key type.
|
|
19
|
+
CURVES = { 'OKP' => 'Ed25519', 'EC' => 'P-256' }.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def ed25519?(key) = key.is_a?(OpenSSL::PKey::PKey) && key.oid == 'ED25519'
|
|
24
|
+
|
|
25
|
+
def p256?(key) = key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == 'prime256v1'
|
|
26
|
+
|
|
27
|
+
# OpenSSL answers `private?` for an EC key only.
|
|
28
|
+
def private?(key)
|
|
29
|
+
return key.private? if key.is_a?(OpenSSL::PKey::EC)
|
|
30
|
+
|
|
31
|
+
key.raw_private_key
|
|
32
|
+
true
|
|
33
|
+
rescue OpenSSL::PKey::PKeyError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `OKP`, `EC`, or nil for a key Kiosk does not use.
|
|
38
|
+
def kty(key)
|
|
39
|
+
if ed25519?(key) then 'OKP'
|
|
40
|
+
elsif p256?(key) then 'EC'
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The JOSE algorithm that `key` signs with, or nil.
|
|
45
|
+
def algorithm(key) = ALGORITHMS[kty(key)]
|
|
46
|
+
|
|
47
|
+
# The public members of `key`: the RFC 7638 required members.
|
|
48
|
+
def public_members(key)
|
|
49
|
+
case kty(key)
|
|
50
|
+
when 'OKP' then { crv: CURVES['OKP'], kty: 'OKP', x: b64(key.raw_public_key) }
|
|
51
|
+
when 'EC'
|
|
52
|
+
point = key.public_key.to_bn(:uncompressed).to_s(2)
|
|
53
|
+
{ crv: CURVES['EC'], kty: 'EC', x: b64(point[1, 32]), y: b64(point[33, 32]) }
|
|
54
|
+
else raise ArgumentError, "Kiosk uses Ed25519 and P-256 keys, got #{key.class}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The public key of a JWK with the public members of its `kty`.
|
|
59
|
+
# @raise [ArgumentError] when the JWK is not an Ed25519 or a P-256 public key
|
|
60
|
+
def import(jwk)
|
|
61
|
+
jwk = jwk.transform_keys(&:to_s)
|
|
62
|
+
kty = jwk['kty']
|
|
63
|
+
raise ArgumentError, "the key type #{kty.inspect} is not OKP or EC" unless CURVES.key?(kty)
|
|
64
|
+
raise ArgumentError, "the curve of an #{kty} key is #{CURVES[kty]}" unless jwk['crv'] == CURVES[kty]
|
|
65
|
+
|
|
66
|
+
public_key(jwk)
|
|
67
|
+
rescue OpenSSL::PKey::PKeyError, ::JWT::JWKError
|
|
68
|
+
raise ArgumentError, "the key is not a valid #{kty} public key"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def public_key(jwk)
|
|
72
|
+
return OpenSSL::PKey.new_raw_public_key('ED25519', Base64.urlsafe_decode64(jwk['x'].to_s)) if jwk['kty'] == 'OKP'
|
|
73
|
+
|
|
74
|
+
::JWT::JWK.import(jwk.slice('kty', 'crv', 'x', 'y')).verify_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# RFC 7638: the SHA-256 of the required members, in alphabetical order.
|
|
78
|
+
def thumbprint(key) = b64(Digest::SHA256.digest(JSON.generate(public_members(key))))
|
|
79
|
+
|
|
80
|
+
def b64(bytes) = Base64.urlsafe_encode64(bytes, padding: false)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|