kiosk-server 0.5.20

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kiosk/uuid_check'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # §11.6 over a PSP adapter: claims the operator's payable row `unpaid →
8
+ # paying` before the capture, checks the cart against the operator's price,
9
+ # flips the row to `paid` once the capture returns, tells its owner on the
10
+ # `payment` topic, and runs `c.after_payment` once the settlement is recorded.
11
+ # A refund through it is recorded against the settlement.
12
+ class PaymentClaim
13
+ UNPAID = 'unpaid'
14
+ PAYING = 'paying'
15
+ PAID = 'paid'
16
+ TOPIC = 'payment'
17
+ EXAMPLE_ID = '5f0c2a8e-3b1d-4c6f-9a7e-2d8b4e1f6c03'
18
+
19
+ attr_reader :currency, :reference
20
+
21
+ # @param model [String] the payable model's name, e.g. "Order"; it includes Kiosk::Owned and Kiosk::Payable
22
+ # @param reference [String] the cart line-item key naming the row, e.g. "order_id"
23
+ # @param query [String] the per-user query that publishes the row's payment state
24
+ def initialize(psp, currency:, model:, reference:, query:)
25
+ @psp = psp
26
+ @currency = currency.to_s.downcase
27
+ @reference = reference
28
+ @query = query
29
+ @row = Row.new(model)
30
+ @options = { currency:, model:, reference:, query: }
31
+ extend SetupReturn if psp.respond_to?(:setup_return_user_id)
32
+ end
33
+
34
+ # The same claim over another PSP adapter.
35
+ def over(psp) = self.class.new(psp, **@options)
36
+
37
+ def capture(cart_mandate, payment_method: nil) = charge_row(claim!(cart_mandate), cart_mandate, payment_method)
38
+
39
+ delegate :setup_required?, :setup_url, to: :@psp
40
+
41
+ # The settlements whose cart names this row.
42
+ def settlements(id) = Kiosk::Settlement.joins(:cart_mandate).merge(Kiosk::CartMandate.referencing(@reference => id))
43
+
44
+ def settled?(id) = settlements(id).exists?
45
+
46
+ # `paying → paid`, then the `payment` event, once: only the call that
47
+ # moved the row announces it. A failure here never surfaces: the charge
48
+ # has happened and the settlement row records it.
49
+ def mark_paid!(id)
50
+ announce(id, PAID) if @row.flip(id, from: PAYING, to: PAID).positive?
51
+ rescue StandardError => e
52
+ FailureLog.report("#{self.class} could not mark #{id} paid", e)
53
+ nil
54
+ end
55
+
56
+ # `paying → unpaid`, for a claim under which no money moved.
57
+ def release!(id) = @row.flip(id, from: PAYING, to: UNPAID)
58
+
59
+ private
60
+
61
+ def charge_row(id, cart_mandate, payment_method)
62
+ settled = charge(id, cart_mandate, payment_method)
63
+ mark_paid!(id)
64
+ settled
65
+ end
66
+
67
+ # Releases the claim when the PSP refuses before any money moved.
68
+ def charge(id, cart_mandate, payment_method)
69
+ @psp.capture(cart_mandate, payment_method: payment_method)
70
+ rescue Kiosk::PaymentProviders::SetupRequired
71
+ release!(id)
72
+ raise
73
+ rescue Kiosk::PaymentProviders::PaymentFailed => e
74
+ release!(id) if e.retryable?
75
+ raise
76
+ end
77
+
78
+ def claim!(cart)
79
+ id = referenced_id(cart)
80
+ refuse_unclaimable!(id, cart.user_id.to_s) unless @row.take(id, cart.user_id.to_s, from: UNPAID, to: PAYING).positive?
81
+ begin
82
+ check_price!(cart, id)
83
+ rescue StandardError
84
+ release!(id)
85
+ raise
86
+ end
87
+ id
88
+ end
89
+
90
+ # The one row the cart's line items name, in the operator's currency.
91
+ def referenced_id(cart)
92
+ deny "cart currency #{cart.currency.inspect} rejected — this operator prices in #{@currency.upcase}" unless cart.currency.to_s.downcase == @currency
93
+ Lines.reference(cart.line_items, @reference)
94
+ end
95
+
96
+ def check_price!(cart, id)
97
+ total = cart.total_amount_cents.to_i
98
+ lines = Array(cart.line_items).reject { |li| li[@reference] }
99
+ Lines.check_sum!(total, lines)
100
+
101
+ price = checker.call(id, lines)
102
+ deny price if price.is_a?(String)
103
+ return if total == price
104
+
105
+ deny "cart total #{total} does not equal this operator's price #{price} for #{noun} #{id} — " \
106
+ 'sign the `cart` that the action which created it returned'
107
+ end
108
+
109
+ def checker
110
+ Kiosk.configuration.cart_price_checker or
111
+ raise Errors::ConfigurationError, "PaymentClaim needs c.cart_price_checker, the operator's price"
112
+ end
113
+
114
+ def refuse_unclaimable!(id, payer)
115
+ status = @row.status(id, payer)
116
+ deny "#{noun} not found or not yours" if status.nil?
117
+ refuse_paying!(id) if status == PAYING
118
+ deny "#{noun} #{id} is already paid (#{status}) — do not pay it again"
119
+ end
120
+
121
+ def refuse_paying!(id)
122
+ if settled?(id)
123
+ mark_paid!(id)
124
+ deny "#{noun} #{id} is already paid"
125
+ end
126
+ deny "#{noun} #{id} has a payment in progress under another chain. Send that chain again, " \
127
+ 'byte for byte: it cannot charge twice. Do not sign a new chain. ' \
128
+ "GET <endpoint>/#{@query} shows the payment state."
129
+ end
130
+
131
+ def noun = @reference.delete_suffix('_id')
132
+
133
+ def deny(message)
134
+ raise Errors::Forbidden, message
135
+ end
136
+
137
+ # The cart's line items: the one row they name, and the priced lines' sum.
138
+ module Lines
139
+ module_function
140
+
141
+ def reference(line_items, key)
142
+ refs = Array(line_items).filter_map { |li| li[key] }.map(&:to_s).uniq
143
+ deny "cart line_items must reference exactly one #{key}" unless refs.size == 1
144
+ id = refs.first
145
+ return id if Kiosk::UuidCheck.valid?(id)
146
+
147
+ raise Errors::BadRequest.new("cart line_items #{key} #{id.inspect} is not a uuid",
148
+ hint: "use the `#{key}` this operator returned, verbatim")
149
+ end
150
+
151
+ # The priced lines, when any, sum to the cart total.
152
+ def check_sum!(total, lines)
153
+ priced = lines.select { |li| li['price_cents'] }
154
+ return if priced.empty?
155
+
156
+ sum = priced.sum do |li|
157
+ qty = li['qty'].to_i
158
+ price = li['price_cents'].to_i
159
+ deny 'each priced line needs a positive qty and price_cents' if qty <= 0 || price <= 0
160
+ qty * price
161
+ end
162
+ deny "cart total #{total} does not equal the sum of its line items #{sum}" unless total == sum
163
+ end
164
+
165
+ def deny(message)
166
+ raise Errors::Forbidden, message
167
+ end
168
+ end
169
+
170
+ # A repeat of a chain whose capture did not complete. The row stays with
171
+ # the newest unfinished chain, and the PSP keys the capture by the cart
172
+ # mandate id, so the repeat cannot charge twice.
173
+ module Resume
174
+ def resume(cart_mandate, payment_method: nil) = charge_row(reclaim!(cart_mandate), cart_mandate, payment_method)
175
+
176
+ private
177
+
178
+ # The row this repeated chain claimed, or the row it may claim now.
179
+ def reclaim!(cart)
180
+ id = referenced_id(cart)
181
+ payer = cart.user_id.to_s
182
+ status = @row.status(id, payer)
183
+ return claim!(cart) if status == UNPAID
184
+ return id if [PAYING, PAID].include?(status) && !settled?(id) && unfinished_chain(id, payer) == cart.id
185
+
186
+ refuse_unclaimable!(id, payer)
187
+ end
188
+
189
+ # The signed id of the newest cart for this row that is neither settled nor declined.
190
+ def unfinished_chain(id, payer)
191
+ Kiosk::CartMandate.referencing(@reference => id).where(user_id: payer, declined_at: nil)
192
+ .where.missing(:settlements).order(created_at: :desc, id: :desc).pick(:mandate_id)
193
+ end
194
+ end
195
+ include Resume
196
+
197
+ # Money the operator gives back, recorded against its settlement.
198
+ module Refund
199
+ # Returns `amount_cents` of the charge `psp_reference` to the card and
200
+ # records the refund against its settlement, so recent spend leaves it
201
+ # out. `key` names this refund among the refunds of the charge: a repeat
202
+ # with the same key is the same refund, and is recorded once. The PSP's receipt.
203
+ def refund(psp_reference:, amount_cents:, key:)
204
+ receipt = @psp.refund(psp_reference:, amount_cents:, key:)
205
+ binds = receipt.values_at(:psp_reference, :refunded_amount_cents, :refunded_at, :refunded_psp_reference)
206
+ ::ActiveRecord::Base.lease_connection.exec_query(format(INSERT_SQL, schema: Kiosk.configuration.schema), 'Kiosk refund', binds)
207
+ receipt
208
+ end
209
+
210
+ INSERT_SQL = <<~SQL.squish
211
+ INSERT INTO "%<schema>s".refunds (settlement_id, psp_reference, refunded_amount_cents, refunded_at)
212
+ SELECT id, $1, $2, $3 FROM "%<schema>s".settlements WHERE psp_reference = $4
213
+ ON CONFLICT (psp_reference) DO NOTHING
214
+ SQL
215
+ end
216
+ include Refund
217
+
218
+ # The operator's work after a payment, which reads the settlement.
219
+ module AfterPayment
220
+ # The executor calls this once, after it records the settlement of
221
+ # `cart_mandate`, so `c.after_payment` reads the row's `settlement`. A
222
+ # failure here never surfaces on the wire: the log carries it.
223
+ def settled(cart_mandate)
224
+ id = Lines.reference(cart_mandate.line_items, @reference)
225
+ Kiosk.configuration.after_payment&.call(id)
226
+ rescue StandardError => e
227
+ FailureLog.report("#{self.class} after_payment failed for #{id}", e)
228
+ nil
229
+ end
230
+ end
231
+ include AfterPayment
232
+
233
+ # The `payment` topic, which the engine serves beside `payment_setup`, and
234
+ # its events: where the money for one row stands.
235
+ module Topic
236
+ # Tells the row's owner where its money stands: `paid` or `refunded`.
237
+ def announce(id, payment_state)
238
+ Events.emit(topic: TOPIC, subject: id, identity_scope: [@row.owner(id)],
239
+ data: { @reference => id, 'payment_state' => payment_state })
240
+ end
241
+
242
+ def topic
243
+ @topic ||= { name: TOPIC, reach: :principal,
244
+ description: "The money for one #{noun} of yours moved. `paid`: the charge went through. " \
245
+ '`refunded`: the charge went back to the card.',
246
+ payload_schema: { type: 'object', additionalProperties: false,
247
+ properties: { @reference => { type: 'string', format: 'uuid' },
248
+ 'payment_state' => { enum: [PAID, Kiosk::Payable::REFUNDED] } },
249
+ required: [@reference, 'payment_state'] },
250
+ examples: [PAID, Kiosk::Payable::REFUNDED].map { { @reference => EXAMPLE_ID, 'payment_state' => it } },
251
+ subject_reachable: ->(id, identity) { !@row.status(id, identity.user_id.to_s).nil? } }.freeze
252
+ end
253
+ end
254
+ include Topic
255
+
256
+ # The PSP names whose human came back from the setup page; the engine
257
+ # serves the `payment_setup` topic only for a provider that has this method.
258
+ module SetupReturn
259
+ delegate :setup_return_user_id, to: :@psp
260
+ end
261
+
262
+ # The operator's payable rows: the status moves under a compare-and-set.
263
+ class Row
264
+ def initialize(model)
265
+ @model = model
266
+ end
267
+
268
+ # Moves this payer's row `from → to`; the count of rows moved, 0 or 1.
269
+ def take(id, payer, from:, to:) = move(model.owned_by(payer).where(id:), from:, to:)
270
+
271
+ def status(id, payer) = model.owned_by(payer).where(id:).pick(:payment_status)
272
+
273
+ def owner(id) = model.where(id:).pick(:user_id)
274
+
275
+ def flip(id, from:, to:) = move(model.where(id:), from:, to:)
276
+
277
+ private
278
+
279
+ def move(rows, from:, to:)
280
+ # One conditional UPDATE is the atomic compare-and-set: the row count says which caller won.
281
+ rows.where(payment_status: from).update_all({ payment_status: to }.merge(timestamps)) # rubocop:disable Rails/SkipsModelValidations -- a per-row save cannot move a row atomically
282
+ end
283
+
284
+ def timestamps
285
+ now = model.current_time_from_proper_timezone
286
+ model.timestamp_attributes_for_update_in_model.index_with { now }
287
+ end
288
+
289
+ def model = @model.constantize
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # `payment_setup`: can this principal pay now, or which page must their
6
+ # human finish first. Served on every origin with a `payment_provider`.
7
+ module PaymentSetup
8
+ NAME = 'payment_setup'
9
+ RETURN_PATH = 'payment_setup/return'
10
+
11
+ DESCRIPTION =
12
+ 'Check whether you can pay now. Returns {status: "ready"} when `pay` will be accepted. ' \
13
+ 'Returns {status: "setup_required", setup_url: "…"} when your human must first open ' \
14
+ 'setup_url and finish there (for example, save a card); call payment_setup again ' \
15
+ 'before paying. Call it before `pay`. ' \
16
+ 'POLLING, where this origin publishes no payment_setup topic: while your human is at ' \
17
+ 'the page, re-check every ~5 seconds for the first minute, then every ~15 seconds, and ' \
18
+ 'GIVE UP after about 5 minutes — tell your human the setup is still not finished rather ' \
19
+ 'than polling indefinitely; they can finish later and you re-check then. ' \
20
+ 'Relay ONE link: if a later check returns a different url, leave your human on the page ' \
21
+ 'they already have open unless they say it stopped working.'
22
+
23
+ INPUT_SCHEMA = { type: 'object', additionalProperties: false, properties: {}, required: [] }.freeze
24
+
25
+ OUTPUT_SCHEMA = {
26
+ oneOf: [
27
+ { type: 'object', additionalProperties: false,
28
+ description: 'Nothing to set up — proceed to `pay`.',
29
+ properties: { status: { const: 'ready' } },
30
+ required: ['status'] },
31
+ { type: 'object', additionalProperties: false,
32
+ description: 'Your human must finish the page at setup_url first.',
33
+ properties: {
34
+ status: { const: 'setup_required' },
35
+ setup_url: { type: 'string', description: 'The page to hand to your human.' }
36
+ },
37
+ required: %w[status setup_url] }
38
+ ]
39
+ }.freeze
40
+
41
+ TOPIC = {
42
+ name: NAME,
43
+ reach: :principal,
44
+ description: 'Your human finished the payment setup. `pay` will now be accepted — ' \
45
+ 'no further payment_setup call is needed.',
46
+ payload_schema: { type: 'object', additionalProperties: false,
47
+ properties: { status: { enum: %w[ready] } },
48
+ required: %w[status] },
49
+ subject_reachable: ->(subject, identity) { subject.to_s == identity.user_id.to_s }
50
+ }.freeze
51
+
52
+ class << self
53
+ def provider = Kiosk.configuration.payment_provider
54
+
55
+ def served!
56
+ return if provider
57
+
58
+ raise Errors::ModuleNotServed.new(
59
+ 'this operator does not serve the payment module',
60
+ hint: "`pay` is absent from this origin's capabilities; hand the transaction to your human"
61
+ )
62
+ end
63
+
64
+ # Publishes the action; the topic when the provider can say whose human
65
+ # came back from the setup page; and a PaymentClaim's `payment` topic.
66
+ def register!
67
+ Actions.declare(NAME, method(:call), description: DESCRIPTION,
68
+ input_schema: INPUT_SCHEMA, output_schema: OUTPUT_SCHEMA)
69
+ Events.register(**TOPIC) if provider.respond_to?(:setup_return_user_id)
70
+ Events.register(**provider.topic) if provider.respond_to?(:topic)
71
+ end
72
+
73
+ def call(_args)
74
+ user_id = CurrentRequest.identity.user_id
75
+ return { 'status' => 'ready' } unless provider.setup_required?(user_id: user_id)
76
+
77
+ { 'status' => 'setup_required', 'setup_url' => provider.setup_url(user_id: user_id, return_url: return_url) }
78
+ end
79
+
80
+ def return_url
81
+ "#{Kiosk.current_issuer.to_s.chomp('/')}#{Kiosk.configuration.mount_path}/#{RETURN_PATH}"
82
+ end
83
+
84
+ # Unauthenticated, so only a hint: the provider names the principal and
85
+ # readiness is asked again. Never raises.
86
+ def returned(params)
87
+ return false unless Events.fetch(NAME)
88
+
89
+ user_id = provider.setup_return_user_id(params).to_s
90
+ return false if user_id.empty? || provider.setup_required?(user_id: user_id)
91
+
92
+ Events.emit(topic: NAME, subject: user_id, identity_scope: [user_id],
93
+ data: { 'status' => 'ready' })
94
+ true
95
+ rescue StandardError => e
96
+ FailureLog.report("#{RETURN_PATH} could not push #{NAME}", e)
97
+ false
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'openssl'
5
+ require 'rails'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # Verifies an agent's proof-of-possession JWS: RS256 by the presented key,
10
+ # `aud` equal to this origin (the relay defence), and the challenge `nonce`.
11
+ # The caller burns the nonce only after a clean verify.
12
+ module PopVerifier
13
+ # Names no origin: an echoed one is what a relaying server would exploit.
14
+ AUDIENCE_HINT =
15
+ 'sign `aud` = the origin you connected to, taken from your own request ' \
16
+ 'URL — never from a value echoed back in a response'
17
+
18
+ PROOF_REQUIRED_CLAIMS = %w[aud nonce jti].freeze
19
+
20
+ MAX_KEY_BITS = 4096
21
+
22
+ PROOF_CLAIMS_HINT =
23
+ "a proof's payload carries #{PROOF_REQUIRED_CLAIMS.join(', ')} — `aud` is the origin " \
24
+ 'you connected to, `nonce` the value GET /auth/challenge just issued for this key, ' \
25
+ '`jti` a unique id for this proof'.freeze
26
+
27
+ PROOF_SIGNATURE_HINT =
28
+ 'a proof is a compact RS256 JWS — header.payload.signature — signed with the ' \
29
+ 'private key matching the public_key you sent'
30
+
31
+ module_function
32
+
33
+ def verify!(public_key_pem:, signed:)
34
+ pem = public_key_pem.to_s.strip
35
+ payload = decode(signed, load_public_key(pem))
36
+ check_audience!(payload[:aud])
37
+ raise Errors::Unauthenticated, 'proof key thumbprint mismatch' if payload.key?(:pub) && payload[:pub] != SigningKey.from_pem(pem).kid
38
+
39
+ payload
40
+ end
41
+
42
+ def decode(signed, key)
43
+ payload, = ::JWT.decode(signed.to_s, key, true, algorithms: ['RS256'], required_claims: PROOF_REQUIRED_CLAIMS)
44
+ payload.transform_keys(&:to_sym)
45
+ rescue ::JWT::MissingRequiredClaim
46
+ raise Errors::Unauthenticated.new('proof is missing a required claim', hint: PROOF_CLAIMS_HINT)
47
+ rescue ::JWT::DecodeError
48
+ raise Errors::Unauthenticated.new('proof signature invalid', hint: PROOF_SIGNATURE_HINT)
49
+ end
50
+
51
+ def check_audience!(signed_aud)
52
+ issuer = Kiosk.current_issuer
53
+ return if signed_aud == issuer
54
+
55
+ log_audience_mismatch(signed_aud: signed_aud, issuer: issuer)
56
+ raise Errors::Unauthenticated.new('proof audience mismatch', hint: AUDIENCE_HINT)
57
+ end
58
+
59
+ # Operator log only, since the response must not name an origin: the
60
+ # caller signed a wrong value, or the origin it reached is not served.
61
+ def log_audience_mismatch(signed_aud:, issuer:)
62
+ message = "[kiosk] PoP audience mismatch: caller signed aud=#{signed_aud.inspect}, " \
63
+ "the issuer for this request is #{issuer.inspect}. If the signed value is " \
64
+ 'an origin your assistants actually reach, list it in `c.issuer` or ' \
65
+ '`c.additional_origins`.'
66
+ logger = ::Rails.logger
67
+ logger ? logger.warn(message) : warn(message)
68
+ end
69
+
70
+ # Also the key bounds of account binding.
71
+ def load_public_key(pem)
72
+ OpenSSL::PKey::RSA.new(pem).tap { require_key_size!(it.n.num_bits) }
73
+ rescue OpenSSL::PKey::PKeyError
74
+ raise Errors::BadRequest.new(
75
+ 'invalid public key',
76
+ hint: "send a PEM-encoded RSA public key of #{SigningKey::MIN_KEY_BITS} to #{MAX_KEY_BITS} bits " \
77
+ '(-----BEGIN PUBLIC KEY----- …)'
78
+ )
79
+ end
80
+
81
+ def require_key_size!(bits)
82
+ raise Errors::BadRequest, "public key too small (#{bits} bits; minimum #{SigningKey::MIN_KEY_BITS})" if bits < SigningKey::MIN_KEY_BITS
83
+ raise Errors::BadRequest, "public key too large (#{bits} bits; maximum #{MAX_KEY_BITS})" if bits > MAX_KEY_BITS
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # The proof-of-work toll, in front of every verb that reaches the executor.
9
+ # It is paid once per access token: after one paid toll, every call made
10
+ # with that token proceeds until the token expires. With no
11
+ # `reputation_policy` it returns `:proceed` and touches nothing;
12
+ # `kiosk-reputation` is required only when a policy is set.
13
+ module PowGate
14
+ module_function
15
+
16
+ # §10, §15.2: SHA256("<METHOD> <verb>\n<canonical args>"), so a proof is
17
+ # spendable on this request only. The proof rides in a header, not the body.
18
+ def request_fingerprint(method:, verb:, body:)
19
+ Digest::SHA256.hexdigest(
20
+ "#{method.to_s.upcase} #{verb}\n#{canonical_json(body || {})}"
21
+ )
22
+ end
23
+
24
+ # `command` is the policy verb ({Executor::VERBS}); `verb` is the wire name
25
+ # the fingerprint binds.
26
+ def gate(identity:, command:, body:, pow:, method: 'POST', verb: nil) # rubocop:disable Metrics/ParameterLists -- the wire controller and the specs pass these six by name
27
+ config = Kiosk.configuration
28
+ policy = config.reputation_policy
29
+ return :proceed if policy.nil? || session_paid?(config, identity)
30
+
31
+ secret = pow_secret!(config)
32
+ spec = toll_for(policy, identity, command.to_sym)
33
+ return :proceed if spec.nil?
34
+
35
+ fp = request_fingerprint(method: method, verb: verb || command, body: body)
36
+ PowToll.new(spec: spec, fingerprint: fp, secret: secret, config: config)
37
+ .collect!(pow).tap { paid(config, identity) }
38
+ end
39
+
40
+ def session_paid?(config, identity)
41
+ session = session_id(identity)
42
+ !session.nil? && config.pow_spent_store.spent?(session)
43
+ end
44
+
45
+ # Records the token's session as paid.
46
+ def paid(config, identity)
47
+ session = session_id(identity)
48
+ config.pow_spent_store.mark_spent(session, identity.claims[:exp]) if session
49
+ end
50
+
51
+ # The access token a toll pays for, by its `jti`, or nil for a caller without one.
52
+ def session_id(identity)
53
+ claims = identity&.claims || {}
54
+ "session:#{claims[:jti]}" if claims[:jti] && claims[:exp]
55
+ end
56
+
57
+ # The policy's toll for this caller and verb, or nil for none.
58
+ def toll_for(policy, identity, verb)
59
+ policy.challenge_for(identity: identity, verb: verb, factors: ReputationFactors.for(identity:, policy:))
60
+ end
61
+
62
+ def pow_secret!(config)
63
+ unless defined?(::Kiosk::Reputation)
64
+ raise Errors::ConfigurationError,
65
+ 'Kiosk::Server: reputation_policy is set but kiosk-reputation is not loaded. ' \
66
+ "Add `require 'kiosk/reputation'` (and `gem 'kiosk-reputation'`) to your app."
67
+ end
68
+
69
+ secret = config.pow_secret
70
+ return secret unless secret.nil? || secret.to_s.strip.empty?
71
+
72
+ raise Errors::ConfigurationError,
73
+ 'Kiosk::Server: reputation_policy is set but pow_secret is nil or empty. ' \
74
+ "Set: Kiosk.configure { |c| c.pow_secret = ENV.fetch('KIOSK_POW_SECRET') }"
75
+ end
76
+
77
+ # One proof, a JSON array, repeated header lines (Rack joins them with
78
+ # "\n") or comma-joined ones; nil when absent.
79
+ def proofs_from_header(raw)
80
+ return nil if raw.nil?
81
+
82
+ proofs = raw.split("\n").map(&:strip).reject(&:empty?).flat_map { |value| proofs_in(value) }
83
+ proofs.empty? ? nil : proofs
84
+ rescue JSON::ParserError
85
+ raise Errors::BadRequest.new('malformed Kiosk-PoW header', hint: POW_HEADER_HINT)
86
+ end
87
+
88
+ def proofs_in(value)
89
+ Array(JSON.parse(value.start_with?('[') ? value : "[#{value}]", symbolize_names: true))
90
+ end
91
+
92
+ POW_HEADER_HINT =
93
+ 'the Kiosk-PoW header carries the proof(s) as raw minified JSON: a single ' \
94
+ 'proof {"challenge": <the challenge object from the 402, echoed verbatim>, ' \
95
+ '"nonce": {"indices": […], "header_nonce"?}}, OR a JSON array of such ' \
96
+ 'proofs [{…},{…}]. Repeated Kiosk-PoW header lines (one proof each) also ' \
97
+ 'work. Solve every challenge issued in the pow_required 402 and echo it ' \
98
+ 'back verbatim.'
99
+
100
+ # Unversioned: the server cannot know which skill cut its caller read.
101
+ POW_SOLVER_URL = 'https://kiosk.tech/pow/solve.py'
102
+
103
+ POW_INVALID_HINT =
104
+ "solve with the reference solver at #{POW_SOLVER_URL} — " \
105
+ 'a hand-written Equihash solver will not match this verifier'.freeze
106
+
107
+ def canonical_json(obj)
108
+ case obj
109
+ when Hash
110
+ pairs = obj.sort_by { |k, _| k.to_s }
111
+ .map { |k, v| "#{JSON.generate(k.to_s)}:#{canonical_json(v)}" }
112
+ "{#{pairs.join(',')}}"
113
+ when Array
114
+ "[#{obj.map { |v| canonical_json(v) }.join(',')}]"
115
+ else
116
+ JSON.generate(obj)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end