kiosk-server 0.5.20 → 0.6.1
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 +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- 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/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- 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/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- 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 +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- 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_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- 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/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
|
@@ -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 it stops counting against the spending cap.
|
|
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.payment_mandates p ON p.
|
|
284
|
-
LEFT JOIN %<schema>s.settlements s ON 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.checkout_mandates c
|
|
284
|
+
JOIN %<schema>s.payment_mandates p ON p.checkout_mandate_id = c.id
|
|
285
|
+
LEFT JOIN %<schema>s.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.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
303
|
INSERT INTO %<schema>s.payment_mandates
|
|
313
|
-
(
|
|
314
|
-
|
|
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
312
|
INSERT INTO %<schema>s.settlements
|
|
322
|
-
(
|
|
323
|
-
settled_amount_cents, currency, settled_at)
|
|
324
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
|
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.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,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# The identity of a wire or event-stream caller: the
|
|
6
|
-
# the configured agent IdP or the bundled
|
|
5
|
+
# The identity of a wire 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
|
data/lib/kiosk/server/jwks.rb
CHANGED
|
@@ -9,6 +9,12 @@ module Kiosk
|
|
|
9
9
|
def build(keys:)
|
|
10
10
|
{ keys: Array(keys).map(&:to_jwk) }
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
# The public key of the JWK in `jwks` whose `kid` is `kid`, or nil.
|
|
14
|
+
def find(jwks, kid)
|
|
15
|
+
jwk = Array(jwks[:keys] || jwks['keys']).find { |k| (k[:kid] || k['kid']) == kid }
|
|
16
|
+
jwk && Jwk.import(jwk)
|
|
17
|
+
end
|
|
12
18
|
end
|
|
13
19
|
end
|
|
14
20
|
end
|
|
@@ -5,16 +5,12 @@ require 'securerandom'
|
|
|
5
5
|
|
|
6
6
|
module Kiosk
|
|
7
7
|
module Server
|
|
8
|
-
# Issues and verifies the
|
|
8
|
+
# Issues and verifies the EdDSA access tokens of the bundled agent IdP.
|
|
9
9
|
module JwtIssuer
|
|
10
|
-
ALGORITHM =
|
|
10
|
+
ALGORITHM = Eddsa::NAME
|
|
11
11
|
DEFAULT_EXPIRES_IN = 3600
|
|
12
12
|
DEFAULT_LEEWAY = 60
|
|
13
13
|
|
|
14
|
-
# A `kid` not in the JWKS: the signature cannot be checked.
|
|
15
|
-
UNKNOWN_KID = ->(e) { e.message.include?('public key for kid') || e.message.include?('Could not find public key') }
|
|
16
|
-
private_constant :UNKNOWN_KID
|
|
17
|
-
|
|
18
14
|
class Error < StandardError; end
|
|
19
15
|
class SignatureError < Error; end
|
|
20
16
|
class ExpiredError < Error; end
|
|
@@ -33,7 +29,7 @@ module Kiosk
|
|
|
33
29
|
raise ArgumentError, 'signing_key must carry a private key for issuance' unless signing_key.private?
|
|
34
30
|
|
|
35
31
|
payload = registered_claims(claims, issuer: issuer, audience: audience, issued_at: now.to_i, expires_in: expires_in)
|
|
36
|
-
::JWT.encode(payload, signing_key.
|
|
32
|
+
::JWT.encode(payload, signing_key.key, ALGORITHM, { kid: signing_key.kid, typ: 'JWT' })
|
|
37
33
|
end
|
|
38
34
|
|
|
39
35
|
def verify(token:, jwks:, audience: nil, issuer: nil, leeway: DEFAULT_LEEWAY)
|
|
@@ -58,9 +54,9 @@ module Kiosk
|
|
|
58
54
|
def decode(token, jwks, audience:, issuer:, leeway:)
|
|
59
55
|
decoded, = ::JWT.decode(
|
|
60
56
|
token, nil, true,
|
|
61
|
-
|
|
57
|
+
algorithms: [ALGORITHM], aud: audience, iss: issuer,
|
|
62
58
|
verify_aud: !audience.nil?, verify_iss: !issuer.nil?, leeway: leeway
|
|
63
|
-
)
|
|
59
|
+
) { |header| Jwks.find(jwks, header['kid']) || raise(SignatureError, 'no key of the JWKS has this kid') }
|
|
64
60
|
symbolize(decoded)
|
|
65
61
|
rescue ::JWT::DecodeError => e
|
|
66
62
|
raise error_class_for(e), e.message
|
|
@@ -70,7 +66,7 @@ module Kiosk
|
|
|
70
66
|
case error
|
|
71
67
|
when ::JWT::ExpiredSignature then ExpiredError
|
|
72
68
|
when ::JWT::InvalidAudError then AudienceError
|
|
73
|
-
when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
69
|
+
when ::JWT::VerificationError, ::JWT::IncorrectAlgorithm then SignatureError
|
|
74
70
|
else InvalidError
|
|
75
71
|
end
|
|
76
72
|
end
|
|
@@ -88,9 +84,7 @@ module Kiosk
|
|
|
88
84
|
end
|
|
89
85
|
end
|
|
90
86
|
|
|
91
|
-
def symbolize(hash)
|
|
92
|
-
hash.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
|
|
93
|
-
end
|
|
87
|
+
def symbolize(hash) = hash.deep_symbolize_keys
|
|
94
88
|
|
|
95
89
|
private_class_method :registered_claims, :decode, :error_class_for
|
|
96
90
|
end
|
|
@@ -11,7 +11,7 @@ module Kiosk
|
|
|
11
11
|
module KycVerifier
|
|
12
12
|
# Named once for the decode and the published hint.
|
|
13
13
|
REQUIRED_CLAIMS = %w[exp iss aud sub].freeze
|
|
14
|
-
SIGNATURE_HINT = 'an attestation is a compact
|
|
14
|
+
SIGNATURE_HINT = 'an attestation is a compact EdDSA JWS signed by the issuer this origin ' \
|
|
15
15
|
'is configured to trust'
|
|
16
16
|
SUBJECT_HINT = "sub must name the principal: the caller's at POST <endpoint>/agents/kyc, " \
|
|
17
17
|
"the open verification's at the callback"
|
|
@@ -49,7 +49,7 @@ module Kiosk
|
|
|
49
49
|
# The verified payload with symbol keys.
|
|
50
50
|
def decode(raw_jws, config)
|
|
51
51
|
payload, = ::JWT.decode(raw_jws, public_key!(config), true,
|
|
52
|
-
algorithms: [
|
|
52
|
+
algorithms: [Eddsa::NAME], verify_expiration: true, required_claims: REQUIRED_CLAIMS)
|
|
53
53
|
payload.transform_keys(&:to_sym)
|
|
54
54
|
rescue ::JWT::ExpiredSignature
|
|
55
55
|
raise Errors::Forbidden, 'KYC attestation expired'
|
|
@@ -4,7 +4,7 @@ module Kiosk
|
|
|
4
4
|
module Server
|
|
5
5
|
# Account binding started by the human: the signed-in holder mints a
|
|
6
6
|
# single-use link code ({.mint}, POST /auth/link) and their assistant
|
|
7
|
-
# redeems it with
|
|
7
|
+
# redeems it with a client assertion and the auth toll ({.redeem}, POST /auth/claim).
|
|
8
8
|
module LinkCode
|
|
9
9
|
CLIENT_ID = 'kiosk-link'
|
|
10
10
|
|
|
@@ -31,37 +31,28 @@ module Kiosk
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# The toll comes before the lookup, so an unknown code costs the same work as a live one.
|
|
34
|
-
|
|
34
|
+
#
|
|
35
|
+
# @return [Client] the client under the account of the human who minted the code
|
|
36
|
+
# @raise [ClientAssertion::Refused] the client assertion is refused
|
|
37
|
+
def redeem(code:, assertion_type:, assertion:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
|
|
35
38
|
store: Kiosk.configuration.device_authorization_store,
|
|
36
39
|
now: Time.current)
|
|
37
40
|
raise Errors::BadRequest, 'code required' if code.to_s.empty?
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
PopVerifier.load_public_key(pem)
|
|
41
|
-
AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s, public_key: pem }, pow: pow)
|
|
42
|
+
AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s }, pow: pow)
|
|
42
43
|
da = live_link(store, code.to_s, now)
|
|
43
44
|
|
|
44
|
-
# The
|
|
45
|
-
|
|
45
|
+
# The client authenticates before the consume, so a refused assertion leaves the code live.
|
|
46
|
+
client = ClientAssertion.authenticate!(assertion_type, assertion)
|
|
46
47
|
# The atomic consume, not `consumed?` below, decides single use under a race.
|
|
47
48
|
raise Errors::Conflict, 'link code already used' if store.claim_consume(da, now: now).nil?
|
|
48
49
|
|
|
49
|
-
bind(
|
|
50
|
+
AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
class << self
|
|
53
54
|
private
|
|
54
55
|
|
|
55
|
-
def prove_possession!(pem, signed)
|
|
56
|
-
payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
|
|
57
|
-
AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def bind(pem, link)
|
|
61
|
-
result = AccountBinding.bind!(public_key_pem: pem, user_id: link.user_id, requested_role: link.requested_role)
|
|
62
|
-
{ agent_id: result[:agent_id], user_id: result[:user_id], access_token: result[:access_token] }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
56
|
def live_link(store, code, now)
|
|
66
57
|
da = store.lookup_by_device_code_hash(DeviceAuthorization.hash_device_code(code))
|
|
67
58
|
unless da&.link?
|