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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. 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 wire paths.
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 wire.
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 AP2 cart. The PSP capture runs outside any transaction,
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 a new chain and pay again.'
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 chain again, byte for byte: this operator charges one chain at ' \
144
- 'most once. Do not sign a new chain.'
145
- CONFLICT_HINT = 'this operator will not charge this chain: an earlier call with it was declined, ' \
146
- 'or these mandate ids came before with other content. Before you sign a new ' \
147
- "chain, read the order's paid state through the operator's own query, and sign " \
148
- 'a new chain only on a clear "not paid".'
149
-
150
- # The verified mandates, the cart row they were persisted under, and
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(:intent, :cart, :payment, :cart_row, :repeat)
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 mandate ids are not burned on a charge that cannot succeed.
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 three signed mandates from the args, by part.
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[intent cart payment].to_h do |part|
186
- raw = args[:"#{part}_mandate_jws"]
187
- raise Errors::BadRequest, "args.#{part}_mandate_jws required" if raw.nil? || raw.to_s.empty?
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 mandate chain. A unique violation means the chain was presented before.
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
- cart = verify_intent_and_cart(chain, raws)
206
- # Before any persist or capture, so a refusal burns nothing.
207
- enforce_spending_cap!(cart)
208
- chain.payment = MandateVerifier.verify_payment(raw_jws: raws[:payment], identity: identity, cart: cart)
209
- chain.cart_row = persist_mandates(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
- def verify_intent_and_cart(chain, raws)
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
- intent_row = persist_intent_mandate(chain.intent)
222
- cart_row = persist_cart_mandate(chain.cart, intent_row_id: intent_row)
223
- persist_payment_mandate(cart_row_id: cart_row, payment: chain.payment)
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 cart, so it stops counting against the spending cap.
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 chain resumes its capture under the same idempotency key.
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.cart, payment_method: chain.payment.payment_method)
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 cart first answers with its settlement.
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.cart) if provider.respond_to?(:settled)
254
- settlement_result(settlement_id, settled[:psp_reference], settled[:settled_amount_cents], chain.cart.currency)
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(cart_row_id: chain.cart_row, cart: chain.cart, settled: settled)
260
+ persist_settlement(checkout_row_id: chain.checkout_row, checkout: chain.checkout, settled: settled)
266
261
  end
267
262
  end
268
263
 
269
- def settlement_result(settlement_id, psp_reference, settled_amount_cents, currency)
270
- Result.new(kind: :value, payload: { settlement_id:, psp_reference:, settled_amount_cents:, currency: })
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
- # Byte-identical chain, scoped to the acting principal on every mandate row, with its settlement if any.
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 cart_row, c.declined_at,
280
- s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency
281
- FROM %<schema>s.cart_mandates c
282
- JOIN %<schema>s.intent_mandates i ON i.id = c.intent_mandate_id
283
- JOIN %<schema>s.payment_mandates p ON p.cart_mandate_id = c.id
284
- LEFT JOIN %<schema>s.settlements s ON s.cart_mandate_id = c.id
285
- WHERE c.user_id = $1 AND i.user_id = $1 AND p.user_id = $1
286
- AND c.mandate_id = $2
287
- AND c.raw_jws = $3
288
- AND i.raw_jws = $4
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
- CART_INSERT_SQL = <<~SQL.squish
304
- INSERT INTO %<schema>s.cart_mandates
305
- (mandate_id, intent_mandate_id, user_id, agent_id, issuer, line_items,
306
- total_amount_cents, currency, expires_at, created_at, raw_jws)
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.payment_mandates
313
- (mandate_id, cart_mandate_id, user_id, agent_id, issuer,
314
- payment_method, amount_cents, currency, expires_at, created_at, raw_jws)
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, so it has no `mandate_id` or `raw_jws`; `UNIQUE (cart_mandate_id)` keeps it single.
310
+ # Nobody signs a settlement; `UNIQUE (checkout_mandate_id)` keeps it single.
320
311
  SETTLEMENT_INSERT_SQL = <<~SQL.squish
321
- INSERT INTO %<schema>s.settlements
322
- (cart_mandate_id, user_id, agent_id, issuer, psp_reference,
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.cart_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
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.cart.id, chain.cart.raw_jws, chain.intent.raw_jws,
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 decline_cart(cart_row)
342
- connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk cart decline', [cart_row])
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
- # References the server intent id; the signed intent-cart binding is checked by MandateVerifier.
353
- def persist_cart_mandate(cart, intent_row_id:)
354
- insert_returning_id('Kiosk cart_mandate insert', CART_INSERT_SQL, [
355
- cart.id, intent_row_id, cart.user_id, cart.agent_id, cart.issuer,
356
- cart.line_items.to_json, cart.total_amount_cents.to_i, cart.currency,
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(cart_row_id:, payment:)
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.id, cart_row_id, payment.user_id, payment.agent_id, payment.issuer,
366
- pm_db, payment.amount_cents.to_i, payment.currency, payment.expires_at,
367
- payment.raw_jws
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(cart_row_id:, cart:, settled:)
351
+ def persist_settlement(checkout_row_id:, checkout:, settled:)
372
352
  insert_returning_id('Kiosk settlement insert', SETTLEMENT_INSERT_SQL, [
373
- cart_row_id, cart.user_id, cart.agent_id, cart.issuer,
374
- settled[:psp_reference], settled[:settled_amount_cents].to_i, cart.currency
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 off the wire: Rails' logger, else `warn`.
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 wire.
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 wire.
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 wire verb;
9
- # a method with no pending declaration stays off the wire.
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 wire authenticates by bearer token, never by cookie, so CSRF protection does not apply.
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 is a wire name: #{NAME_PATTERN.inspect}."
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
- # Wire name → declaration for the verbs on this class, one per name.
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
- # Wire name → declaration for the topics on this class.
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 wire request.
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 wire code, its own message going to the operator's log and
380
- # never onto the wire; anything else is re-raised.
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 wire request to the action that declares
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
- # wire answer.
11
+ # protocol answer.
12
12
  module HandlerServing
13
13
  include VerbWire
14
14
 
15
- # The Rack env key where {Routing::VerbRoute} puts the wire name it routed.
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 wire: `Cache-Control`, on a 2xx (§3.7.4).
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 wire's gates, then the action inside the session.
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 wire answers JSON whatever the caller's
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 wire answer is written into a fresh response.
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 wire only',
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 wire error it means.
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 wire error it means.
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 on the wire'
175
+ '`send_file` have no place in a Kiosk response'
176
176
  )
177
177
  end
178
178
 
@@ -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 wire response.
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 wire `Vary`; forces `no-store` on a 402; defaults to
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 wire response: ' \
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 wire or event-stream caller: the bearer token, read by
6
- # the configured agent IdP or the bundled kiosk-pop one. A cookie session
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