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
@@ -1,21 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 005 — kiosk AP2 mandate trail:
4
- # intent_mandates, cart_mandates, payment_mandates, settlements.
5
- # See the Payment (AP2 mandate chain) section of the spec.
3
+ # The AP2 trail of a `pay`: the checkout mandate, the payment mandate and the
4
+ # settlement. `reference` is the hash of the presented SD-JWT, unique per user.
5
+ # A checkout has one live checkout mandate: `declined_at` records a charge
6
+ # refused with no money moved, and frees the checkout.
6
7
  class CreateKioskMandates < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:mandates, user_id_type: user_id_type) %>
10
- SQL
11
- end
8
+ def change
9
+ create_table <%= table :checkout_mandates %>, id: :uuid do |t|
10
+ t.text :reference, null: false
11
+ t.text :checkout_hash, null: false
12
+ t.column :user_id, <%= user_id_type %>, null: false
13
+ t.uuid :agent_id, null: false
14
+ t.text :issuer, null: false
15
+ t.jsonb :line_items, null: false
16
+ t.bigint :total_amount_cents, null: false
17
+ t.text :currency, null: false
18
+ t.timestamptz :expires_at, null: false
19
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
20
+ t.text :sd_jwt, null: false
21
+ t.timestamptz :declined_at
22
+ t.index %i[user_id reference], unique: true
23
+ t.index %i[user_id checkout_hash], unique: true, where: 'declined_at IS NULL'
24
+ end
25
+
26
+ create_table <%= table :payment_mandates %>, id: :uuid do |t|
27
+ t.text :reference, null: false
28
+ t.references :checkout_mandate, type: :uuid, null: false,
29
+ foreign_key: { to_table: <%= table :checkout_mandates %>, on_delete: :cascade }
30
+ t.column :user_id, <%= user_id_type %>, null: false
31
+ t.uuid :agent_id, null: false
32
+ t.text :issuer, null: false
33
+ t.text :payment_instrument, null: false
34
+ t.bigint :amount_cents, null: false
35
+ t.text :currency, null: false
36
+ t.timestamptz :expires_at
37
+ t.timestamptz :created_at, null: false, default: -> { 'now()' }
38
+ t.text :sd_jwt, null: false
39
+ t.index %i[user_id reference], unique: true
40
+ end
12
41
 
13
- def down
14
- # Reverse-FK order: payment_mandates and settlements both reference
15
- # cart_mandates, so both drop before it; cart_mandates before intent_mandates.
16
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".payment_mandates)
17
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".settlements)
18
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".cart_mandates)
19
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".intent_mandates)
42
+ # The server writes a settlement; nobody signs it. One per checkout mandate.
43
+ create_table <%= table :settlements %>, id: :uuid do |t|
44
+ t.references :checkout_mandate, type: :uuid, null: false, index: { unique: true },
45
+ foreign_key: { to_table: <%= table :checkout_mandates %>, on_delete: :cascade }
46
+ t.column :user_id, <%= user_id_type %>, null: false, index: true
47
+ t.uuid :agent_id, null: false
48
+ t.text :issuer, null: false
49
+ t.text :psp_reference, null: false
50
+ t.bigint :settled_amount_cents, null: false
51
+ t.text :currency, null: false
52
+ t.text :network_confirmation_id
53
+ t.timestamptz :settled_at, null: false
54
+ end
20
55
  end
21
56
  end
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 008 — create kiosk.pow_spent.
4
- # The spent proof-of-work challenge ids, so a proof is accepted once across
5
- # every process and every deploy (PowSpentStores::ActiveRecord, the default).
3
+ # The spent ids of proof-of-work challenges and client assertions. The primary
4
+ # key accepts each id once, in every process and across deploys.
6
5
  class CreateKioskPowSpent < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:pow_spent) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".pow_spent)
6
+ def change
7
+ create_table <%= table :pow_spent %>, id: :text do |t|
8
+ # The expiry sweep reads it.
9
+ t.timestamptz :expires_at, null: false, index: true
10
+ end
15
11
  end
16
12
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 012 — create kiosk.refunds.
4
- # The refunds of settled charges (Kiosk::Server::PaymentClaim#refund), which
5
- # recent spend leaves out of the proof-of-work toll.
3
+ # The refunds of settled charges, one row per PSP refund. Recent spend counts
4
+ # a settlement less what was refunded of it.
6
5
  class CreateKioskRefunds < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:refunds) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".refunds)
6
+ def change
7
+ create_table <%= table :refunds %>, id: :uuid do |t|
8
+ t.references :settlement, type: :uuid, null: false,
9
+ foreign_key: { to_table: <%= table :settlements %>, on_delete: :cascade }
10
+ t.text :psp_reference, null: false, index: { unique: true }
11
+ t.bigint :refunded_amount_cents, null: false
12
+ t.timestamptz :refunded_at, null: false
13
+ t.check_constraint 'refunded_amount_cents > 0', name: 'kiosk_refunds_refunded_amount_cents'
14
+ end
15
15
  end
16
16
  end
@@ -1,16 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 009 — create kiosk.revocations.
4
- # Each agent's token revocation watermark, so a revoke or an unlink holds in
5
- # every process and across restarts (RevocationStores::ActiveRecord, the default).
3
+ # Each agent's revocation watermark: a token issued before it stops verifying,
4
+ # in every process and after a restart.
6
5
  class CreateKioskRevocations < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:revocations) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".revocations)
6
+ def change
7
+ create_table <%= table :revocations %>, primary_key: :agent_id, id: :uuid, default: nil do |t|
8
+ t.timestamptz :revoked_before, null: false
9
+ t.foreign_key <%= table :agents %>, column: :agent_id, on_delete: :cascade
10
+ end
15
11
  end
16
12
  end
@@ -1,16 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Migration 011 — create kiosk.visits.
4
- # The visits the operator records as fulfilled (Kiosk::Visit.record!), which
5
- # lower the proof-of-work toll for a returning customer.
3
+ # The visits the operator records as fulfilled (Kiosk::Visit.record!). Their
4
+ # value lowers the proof-of-work toll for a returning customer.
6
5
  class CreateKioskVisits < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
- def up
8
- execute <<~'SQL'
9
- <%= ddl(:visits, user_id_type: user_id_type) %>
10
- SQL
11
- end
12
-
13
- def down
14
- execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".visits)
6
+ def change
7
+ create_table <%= table :visits %>, id: :uuid do |t|
8
+ t.column :user_id, <%= user_id_type %>, null: false
9
+ t.bigint :value_cents, null: false
10
+ t.timestamptz :visited_at, null: false, default: -> { 'now()' }
11
+ t.check_constraint 'value_cents >= 0', name: 'kiosk_visits_value_cents'
12
+ t.index %i[user_id visited_at]
13
+ end
15
14
  end
16
15
  end
@@ -9,17 +9,21 @@ Kiosk.configure do |c|
9
9
  # Type of users.id: :uuid (default), :bigint, :integer or :text.
10
10
  c.user_id_type = :<%= options[:user_id_type] %>
11
11
 
12
- # coerce-then-validate on the per-verb wire is UNCONDITIONAL: arguments are
12
+ # coerce-then-validate on the per-verb endpoints is UNCONDITIONAL: arguments are
13
13
  # always checked against the verb's `input_schema` (§8.1). This flag adds a check:
14
14
  # it validates every answer against the verb's `output_schema`. Off in production,
15
15
  # where a descriptor typo would become a 500 for a caller.
16
16
  c.validate_responses = !Rails.env.production?
17
17
 
18
18
  # GUC namespace for SET LOCAL (and RLS policies). Default "app".
19
- # c.guc_namespace = "<%= options[:guc_namespace] %>"
19
+ # c.guc_namespace = "app"
20
20
 
21
- # Postgres schema for Kiosk helper functions and tables.
22
- # c.schema = "<%= options[:schema] %>"
21
+ # Postgres schema of Kiosk's kiosk_* tables. Default "public".
22
+ <% if public_schema? -%>
23
+ # c.schema = "public"
24
+ <% else -%>
25
+ c.schema = "<%= options[:schema] %>"
26
+ <% end -%>
23
27
 
24
28
  # Opt-in Postgres RLS: add `gem "kiosk-rls"` and create these database roles yourself.
25
29
  # c.app_role = "app_role" # runtime role with NOBYPASSRLS
@@ -42,6 +46,11 @@ Kiosk.configure do |c|
42
46
  # Owner block surfaced in /.well-known/kiosk.json.
43
47
  # c.owner = { name: "Your Company", support: "support@example.com" }
44
48
 
49
+ # The SHA-256 of the skill file that kiosk-server gives. Without it, discovery advertises no skill pin.
50
+ # curl -fsS "$(bin/rails runner 'puts Kiosk.configuration.skill_url')" | shasum -a 256
51
+ # Kiosk::TestHelpers::SkillPin (kiosk-test-support) fails when an upgrade gives a different skill.
52
+ # c.skill_sha256 = "<the 64 hexadecimal characters that the command prints>"
53
+
45
54
  # Optional: an agent IdP of your own (subclass Kiosk::AgentIdentityProviders::Base; agent ids must be UUIDs).
46
55
  # c.agent_idp = MyExternalAgentIdp.new
47
56
 
data/lib/kiosk/payable.rb CHANGED
@@ -4,8 +4,8 @@ require 'active_support/concern'
4
4
 
5
5
  module Kiosk
6
6
  # `include Kiosk::Payable` in the model that `Kiosk::Server::PaymentClaim`
7
- # names. It gives the row its `payment_state`, the `cart` an assistant signs
8
- # to pay for it, and the `settlement` that paid it.
7
+ # names. It gives the row its `payment_state`, the `checkout_jwt` an
8
+ # assistant's mandates bind to pay for it, and the `settlement` that paid it.
9
9
  module Payable
10
10
  extend ActiveSupport::Concern
11
11
 
@@ -22,25 +22,19 @@ module Kiosk
22
22
  '`refunded`: the charge went back to the card.'
23
23
  }.freeze
24
24
 
25
- # The `cart` member of the answer of an action that creates a payable row.
26
- CART_SCHEMA = {
27
- type: 'object', additionalProperties: false,
28
- description: 'Sign the cart mandate with these three values, unchanged.',
29
- properties: {
30
- currency: { type: 'string' },
31
- total_amount_cents: { type: 'integer' },
32
- line_items: { type: 'array', items: { type: 'object' } }
33
- },
34
- required: %w[currency total_amount_cents line_items]
25
+ # The `checkout_jwt` member of the answer of an action that creates a payable row.
26
+ CHECKOUT_SCHEMA = {
27
+ type: 'string',
28
+ description: 'The checkout to pay, signed by this operator. Put it unchanged in your checkout mandate.'
35
29
  }.freeze
36
30
 
37
31
  class_methods do
38
32
  # Adds `settled_currency`, the currency of a settlement among `settlements`
39
- # whose cart names the row, or NULL: the caller's own on the wire, all of
40
- # them in a back office.
33
+ # whose checkout names the row, or NULL: the caller's own in the protocol, all
34
+ # of them in a back office.
41
35
  def with_settlement(settlements)
42
36
  select(arel_table[Arel.star],
43
- Arel::Nodes::Grouping.new(settlements.joins(:cart_mandate).where(names_row).select(:currency)
37
+ Arel::Nodes::Grouping.new(settlements.joins(:checkout_mandate).where(names_row).select(:currency)
44
38
  .limit(1).arel).as('settled_currency'))
45
39
  end
46
40
 
@@ -48,19 +42,19 @@ module Kiosk
48
42
 
49
43
  private
50
44
 
51
- # `cart_mandates.line_items @> [{"<reference>": "<this row's id>"}]`.
45
+ # `checkout_mandates.line_items @> [{"<reference>": "<this row's id>"}]`.
52
46
  def names_row
53
47
  line = Arel::Nodes::NamedFunction.new(
54
48
  'jsonb_build_object', [Arel::Nodes.build_quoted(payment_claim.reference),
55
49
  Arel::Nodes::NamedFunction.new('CAST', [arel_table[:id].as(Arel.sql('text'))])]
56
50
  )
57
- Arel::Nodes::InfixOperation.new('@>', Kiosk::CartMandate.arel_table[:line_items],
51
+ Arel::Nodes::InfixOperation.new('@>', Kiosk::CheckoutMandate.arel_table[:line_items],
58
52
  Arel::Nodes::NamedFunction.new('jsonb_build_array', [line]))
59
53
  end
60
54
  end
61
55
 
62
56
  # `pending` means a capture is in flight: the caller reads again and does
63
- # not sign a new mandate. A row read without `with_settlement` asks the
57
+ # not sign new mandates. A row read without `with_settlement` asks the
64
58
  # settlements itself.
65
59
  def payment_state
66
60
  status = self[:payment_status]
@@ -71,16 +65,16 @@ module Kiosk
71
65
  UNPAID
72
66
  end
73
67
 
74
- # The cart that pays for this row: a line naming the row, then `lines`,
75
- # each `{qty:, price_cents:, ...}`, which sum to the total.
76
- def cart(lines)
68
+ # The signed checkout that pays for this row, for its owner: a line naming
69
+ # the row, then `lines`, each `{qty:, price_cents:, ...}`, which sum to the total.
70
+ def checkout(lines)
77
71
  claim = self.class.payment_claim
78
- { currency: claim.currency,
79
- total_amount_cents: lines.sum { it[:qty] * it[:price_cents] },
80
- line_items: [{ claim.reference => id }, *lines] }
72
+ Kiosk::Server::Checkout.sign(user_id: user_id, currency: claim.currency,
73
+ total_amount_cents: lines.sum { it[:qty] * it[:price_cents] },
74
+ line_items: [{ claim.reference => id }, *lines])
81
75
  end
82
76
 
83
- # The first settlement whose cart names this row, or nil.
77
+ # The first settlement whose checkout names this row, or nil.
84
78
  def settlement = self.class.payment_claim.settlements(id).order(:created_at).first
85
79
 
86
80
  private
@@ -2,33 +2,28 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # Binds a proven public key to a human's account. A fresh key gets a new
6
- # linked agent row; a known key is rebound, keeping its agent_id and
7
- # reputation and adopting the new holder's role.
5
+ # Binds a registered client to a human's account: the client keeps its
6
+ # agent_id and reputation and takes the role of its new holder. The first
7
+ # binding merges the assistant account into the human's account.
8
8
  module AccountBinding
9
9
  module_function
10
10
 
11
- # Call only after possession of `public_key_pem` has been proven.
12
- # `requested_role` always comes from a human's identity, never the wire.
11
+ # Call only after the client has authenticated.
12
+ # `requested_role` always comes from a human's identity, never the request.
13
13
  #
14
- # @return [Hash] { agent_id:, user_id:, access_token:, fresh: }
15
- def bind!(public_key_pem:, user_id:, requested_role: nil)
14
+ # @return [Client] the client under its new holder
15
+ def bind!(agent_id:, user_id:, requested_role: nil)
16
16
  config = Kiosk.configuration
17
- pem = public_key_pem.to_s.strip
18
17
  raise ArgumentError, 'user_id required' if user_id.nil? || user_id.to_s.empty?
19
18
 
19
+ client = Client.find(agent_id) || raise(ArgumentError, "no live client #{agent_id.inspect} at this origin")
20
20
  warn_role_resolution_not_total(config, requested_role)
21
-
22
- # `lease_connection`: the operator's hooks must share this transaction.
23
- conn = ::ActiveRecord::Base.lease_connection
24
- existing = Rows.live_by_key(conn, config.schema, pem)
25
- return rebind(conn, config, existing, user_id, requested_role) if existing
26
-
27
- register_linked(conn, config, pem, user_id, requested_role)
21
+ rebind(::ActiveRecord::Base.lease_connection, config, client, user_id, requested_role)
22
+ Client.find(agent_id)
28
23
  end
29
24
 
30
25
  # Revokes `agent_id`'s binding, which must belong to `user_id`; its tokens
31
- # stop verifying and its `/auth/login` is denied.
26
+ # stop verifying and the token endpoint refuses it.
32
27
  #
33
28
  # @raise [Errors::NotFound] when no live agent row matches the pair.
34
29
  def unlink!(agent_id:, user_id:)
@@ -59,27 +54,32 @@ module Kiosk
59
54
  class << self
60
55
  private
61
56
 
62
- # Known key: remap the holder and role, keep agent_id and reputation.
57
+ # Remaps the holder and role, keeps agent_id and reputation.
63
58
  # The role is never read from the row: it belonged to the previous holder.
64
- def rebind(conn, config, existing, user_id, requested_role = nil)
65
- agent_id = existing.fetch('id')
66
- previous = existing.fetch('user_id')
67
- role = resolved_role(config, requested_role)
59
+ def rebind(conn, config, client, user_id, requested_role)
60
+ role = resolved_role(config, requested_role)
68
61
  # The same holder still gets the role remap and revocation; only the hook is skipped.
69
- transition = previous.to_s != user_id.to_s
62
+ transition = client.user_id.to_s != user_id.to_s
70
63
  conn.transaction do
71
- Rows.rebind(conn, config.schema, agent_id, user_id, role)
72
- claimed(config, agent_id, previous, user_id) if transition && config.assistant_claimed
64
+ Rows.rebind(conn, config.schema, client.id, user_id, role)
65
+ carry_reputation(client.user_id, user_id) if transition
66
+ claimed(config, client.id, client.user_id, user_id) if transition && config.assistant_claimed
73
67
  end
74
68
  # The old holder's tokens die (§6.3). The IdP dates new mints at the
75
- # watermark, so the token minted below survives it.
76
- revoke_tokens(config, agent_id)
77
- { agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: false }
69
+ # watermark, so a token minted after this survives it.
70
+ revoke_tokens(config, client.id)
71
+ end
72
+
73
+ # The new account adds the previous account's count of tolled calls,
74
+ # and the first binding merges the assistant account into it.
75
+ def carry_reputation(from, to)
76
+ TolledCalls.carry!(from:, to:)
77
+ AssistantAccounts.merge!(from:, into: to)
78
78
  end
79
79
 
80
- # Next second: JWT `iat` is whole seconds and the check is `iat < watermark` (§6.3).
80
+ # §6.3: the tokens of the previous holder stop.
81
81
  def revoke_tokens(config, agent_id)
82
- config.revocation_store.revoke_all(agent_id, at: Time.now.to_i + 1)
82
+ RevocationStores.revoke_issued(agent_id, store: config.revocation_store)
83
83
  end
84
84
 
85
85
  def claimed(config, agent_id, previous, user_id)
@@ -87,13 +87,6 @@ module Kiosk
87
87
  config.assistant_claimed.call(agent: agent_id, from: accounts.find(previous), to: accounts.find(user_id))
88
88
  end
89
89
 
90
- # Fresh key: a new linked agent row under the approving human.
91
- def register_linked(conn, config, pem, user_id, requested_role)
92
- role = resolved_role(config, requested_role)
93
- agent_id = conn.transaction { Rows.insert_linked(conn, config.schema, user_id, pem, role) }
94
- { agent_id: agent_id, user_id: user_id.to_s, access_token: issue_token(agent_id, role), fresh: true }
95
- end
96
-
97
90
  def not_linked
98
91
  Errors::NotFound.new(
99
92
  'no linked assistant account with this agent_id',
@@ -130,27 +123,15 @@ module Kiosk
130
123
  logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
131
124
  logger ? logger.warn(message) : Kernel.warn(message)
132
125
  end
133
-
134
- def issue_token(agent_id, role)
135
- AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
136
- end
137
126
  end
138
127
 
139
128
  # The agents-table statements a binding runs.
140
129
  module Rows
141
130
  module_function
142
131
 
143
- def live_by_key(conn, schema, pem)
144
- conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
145
- SELECT id, user_id FROM #{schema}.agents
146
- WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
147
- LIMIT 1
148
- SQL
149
- end
150
-
151
132
  def revoke_owned(conn, schema, agent_id, user_id)
152
133
  conn.exec_query(<<~SQL.squish, 'Kiosk agent unlink', [agent_id, user_id, Kiosk.current_issuer]).to_a.first
153
- UPDATE #{schema}.agents
134
+ UPDATE #{schema}.kiosk_agents
154
135
  SET revoked_at = now()
155
136
  WHERE id = $1
156
137
  AND user_id = $2
@@ -165,22 +146,11 @@ module Kiosk
165
146
  role_set, role_binds =
166
147
  role ? [', allowed_roles = ARRAY[$3]::text[]', [role]] : [", allowed_roles = '{}'::text[]", []]
167
148
  conn.exec_query(<<~SQL.squish, 'Kiosk agent rebind', [user_id, agent_id, *role_binds])
168
- UPDATE #{schema}.agents
169
- SET user_id = $1#{role_set}
149
+ UPDATE #{schema}.kiosk_agents
150
+ SET user_id = $1, bound_at = now()#{role_set}
170
151
  WHERE id = $2
171
152
  SQL
172
153
  end
173
-
174
- # The column is NOT NULL: no role is `'{}'`, never NULL.
175
- def insert_linked(conn, schema, user_id, pem, role)
176
- allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
177
- rows = conn.exec_query(<<~SQL.squish, 'Kiosk linked agent insert', [user_id, pem, Kiosk.current_issuer, *role_binds])
178
- INSERT INTO #{schema}.agents (user_id, allowed_roles, public_key, issuer)
179
- VALUES ($1, #{allowed_roles_sql}, $2, $3)
180
- RETURNING id
181
- SQL
182
- rows.to_a.first.fetch('id')
183
- end
184
154
  end
185
155
  private_constant :Rows
186
156
  end
@@ -4,7 +4,8 @@ module Kiosk
4
4
  module Server
5
5
  # Requires a human signed in through the operator's own session
6
6
  # (`user_idp`), never an agent token. A browser is sent to `sign_in_path`;
7
- # any other caller gets a 401 naming where to sign in.
7
+ # any other caller gets a 401 with the `Kiosk-Session` challenge, naming
8
+ # where to sign in.
8
9
  module AccountHolderGate
9
10
  private
10
11
 
@@ -22,6 +23,7 @@ module Kiosk
22
23
  store_return_location
23
24
  redirect_to sign_in_path
24
25
  else
26
+ response.set_header('WWW-Authenticate', Errors::SessionRequired.challenge)
25
27
  render plain: sign_in_path ? "#{prompt} #{sign_in_directions(sign_in_path)}" : prompt,
26
28
  status: :unauthorized
27
29
  end
@@ -5,14 +5,14 @@ require 'kiosk/agent_identity_providers/base'
5
5
  module Kiosk
6
6
  module Server
7
7
  module AgentIdentityProviders
8
- # The bundled agent IdP: RS256 tokens signed with the origin's own key;
9
- # payment keys from the agents table.
8
+ # The bundled agent IdP: EdDSA tokens signed with the origin's own key and
9
+ # bound to the agent's registered key (`cnf.jkt`); payment keys from the agents table.
10
10
  class DefaultAgentIdp < Kiosk::AgentIdentityProviders::Base
11
11
  def verify(request)
12
- header = authorization_for(request)
13
- return nil if header.blank?
12
+ token = Dpop.token(authorization_for(request))
13
+ return nil if token.nil?
14
14
 
15
- claims = verified_claims(header.sub(/\ABearer\s+/i, ''))
15
+ claims = verified_claims(token)
16
16
  Kiosk::Identity.new(
17
17
  user_id: claims[:sub], role: claims[:role], actor: 'agent',
18
18
  agent_id: claims[:agent_id], claims: claims
@@ -22,7 +22,9 @@ module Kiosk
22
22
  end
23
23
 
24
24
  def issue(agent_id:, role:)
25
- claims = { sub: lookup_user_id(agent_id), agent_id: agent_id, actor: 'agent' }
25
+ agent = lookup_agent(agent_id)
26
+ claims = { sub: agent.fetch('user_id'), agent_id: agent_id, actor: 'agent',
27
+ cnf: { jkt: AssistantKey.thumbprint(AssistantKey.from_pem(agent.fetch('public_key'))) } }
26
28
  claims[:role] = role.to_s unless role.nil? || role.to_s.empty?
27
29
  JwtIssuer.issue(
28
30
  claims: claims, audience: Kiosk.current_issuer, now: mint_instant(agent_id),
@@ -31,10 +33,10 @@ module Kiosk
31
33
  end
32
34
 
33
35
  def agent_payment_key(agent_id)
34
- pem = agents_column('public_key', agent_id)&.fetch('public_key', nil)
36
+ pem = agent_row('public_key', agent_id)&.fetch('public_key', nil)
35
37
  raise Kiosk::AgentIdentityProviders::InvalidToken, "no key for agent #{agent_id}" if pem.nil?
36
38
 
37
- OpenSSL::PKey::RSA.new(pem)
39
+ AssistantKey.from_pem(pem)
38
40
  end
39
41
 
40
42
  private
@@ -42,7 +44,7 @@ module Kiosk
42
44
  def verified_claims(token)
43
45
  JwtIssuer.verify(
44
46
  token: token,
45
- jwks: Jwks.build(keys: [Kiosk.configuration.signing_key]),
47
+ jwks: Jwks.build(keys: Kiosk.configuration.signing_keys),
46
48
  audience: Kiosk.current_issuer,
47
49
  issuer: Kiosk.current_issuer
48
50
  )
@@ -58,18 +60,18 @@ module Kiosk
58
60
  Time.at(watermark).utc
59
61
  end
60
62
 
61
- def lookup_user_id(agent_id)
62
- row = agents_column('user_id', agent_id)
63
+ def lookup_agent(agent_id)
64
+ row = agent_row('user_id, public_key', agent_id)
63
65
  raise Kiosk::AgentIdentityProviders::InvalidToken, "unknown agent #{agent_id}" if row.nil?
64
66
 
65
- row.fetch('user_id')
67
+ row
66
68
  end
67
69
 
68
- # `column` is always a literal from this class, never caller input.
69
- def agents_column(column, agent_id)
70
+ # `columns` is always a literal from this class, never caller input.
71
+ def agent_row(columns, agent_id)
70
72
  ::ActiveRecord::Base.lease_connection.exec_query(
71
- "SELECT #{column} FROM #{schema}.agents WHERE id = $1 AND revoked_at IS NULL",
72
- "Kiosk agent #{column}",
73
+ "SELECT #{columns} FROM #{schema}.kiosk_agents WHERE id = $1 AND revoked_at IS NULL",
74
+ "Kiosk agent #{columns}",
73
75
  [agent_id]
74
76
  ).to_a.first
75
77
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Puts a new key on a registered client (spec Section 5.7). The registered
6
+ # key and the new key each sign a client assertion, and the assertion of
7
+ # the registered key names the new key, so the client keeps its id,
8
+ # account, role and reputation. Every earlier token is revoked.
9
+ module AgentRotation
10
+ NEW_KEY_HINT = 'the client assertion of your registered key carries `new_key`: the RFC 7638 ' \
11
+ 'thumbprint of the key in jwks, so the registered key names the key that replaces it'
12
+
13
+ SAME_CLIENT_HINT = 'sign both client assertions with your own client_id'
14
+
15
+ module_function
16
+
17
+ # @return [Client] the client with its new key
18
+ # @raise [ClientAssertion::Refused, Errors::BadRequest, Errors::Conflict]
19
+ def call(type:, assertion:, new_assertion:, jwks:)
20
+ new_key = AssistantKey.from_jwks(jwks)
21
+ client = prove_both!(type, assertion, new_assertion, new_key)
22
+ refuse_known_key!(new_key)
23
+ replace_key(client.id, AssistantKey.to_pem(new_key))
24
+ Client.find(client.id)
25
+ end
26
+
27
+ # Both assertions are verified, then both are spent, before any change.
28
+ def prove_both!(type, assertion, new_assertion, new_key)
29
+ old = ClientAssertion.verify(type, assertion)
30
+ new = ClientAssertion.verify(type, new_assertion, key: new_key)
31
+ refuse!('the client assertion does not name the new key', NEW_KEY_HINT) unless old.claims['new_key'] == AssistantKey.thumbprint(new_key)
32
+ refuse!('the two client assertions name two clients', SAME_CLIENT_HINT) unless new.client.id == old.client.id
33
+
34
+ [old, new].each { ClientAssertion.spend!(it) }
35
+ old.client
36
+ end
37
+
38
+ def refuse!(message, hint)
39
+ raise ClientAssertion::Refused.new(message, hint: hint)
40
+ end
41
+
42
+ def refuse_known_key!(key)
43
+ return unless Client.with_key(key)
44
+
45
+ raise Errors::Conflict.new('the new key is registered already',
46
+ hint: 'rotate to a key that this origin does not know')
47
+ end
48
+
49
+ # The key and the revocation watermark move together, so no token outlives the old key.
50
+ def replace_key(agent_id, pem)
51
+ conn = ::ActiveRecord::Base.lease_connection
52
+ conn.transaction do
53
+ conn.exec_query(<<~SQL.squish, 'Kiosk agent key rotation', [pem, agent_id])
54
+ UPDATE #{Kiosk.configuration.schema}.kiosk_agents SET public_key = $1 WHERE id = $2
55
+ SQL
56
+ RevocationStores.revoke_issued(agent_id)
57
+ end
58
+ end
59
+ private_class_method :prove_both!, :refuse!, :refuse_known_key!, :replace_key
60
+ end
61
+ end
62
+ end
@@ -56,7 +56,7 @@ module Kiosk
56
56
  end
57
57
  end
58
58
 
59
- # What a caller has to send for each string `format` the wire reads.
59
+ # What a caller has to send for each string `format` the protocol reads.
60
60
  FORMAT_SPELLINGS = {
61
61
  'date' => 'a calendar date as YYYY-MM-DD, e.g. 2026-08-19',
62
62
  'date-time' => 'an ISO 8601 timestamp, e.g. 2026-08-19T14:00:00Z'
@@ -76,7 +76,7 @@ module Kiosk
76
76
  "an object argument's leaves are SCALARS: `o%5Bk%5D=v`, not " \
77
77
  '`o%5Bk%5D%5B%5D=v`. Anything richer is an ACTION (POST).'
78
78
 
79
- # The query string's shape: what Rack can decode and the wire accepts.
79
+ # The query string's shape: what Rack can decode and the protocol accepts.
80
80
  module QueryShape
81
81
  module_function
82
82