kiosk-server 0.5.20 → 0.6.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +192 -138
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +21 -65
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +20 -0
- data/app/models/kiosk/settlement.rb +1 -3
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -69
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +32 -62
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +6 -6
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +154 -0
- data/lib/kiosk/server/client_registration.rb +98 -0
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +64 -75
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +126 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +16 -13
- data/lib/kiosk/server/errors.rb +28 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +38 -10
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +17 -76
- data/lib/kiosk/server/executor.rb +86 -105
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +28 -29
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +22 -17
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +31 -0
- data/lib/kiosk/server/oauth_error.rb +33 -0
- data/lib/kiosk/server/open_api.rb +31 -29
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +47 -78
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +30 -0
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +3 -3
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +9 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +369 -58
- data/lib/kiosk/server/schemas/binding.schema.json +24 -59
- data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
- data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +4 -4
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +272 -205
- data/lib/kiosk/server/wire_rendering.rb +2 -2
- data/lib/kiosk/server.rb +8 -2
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +34 -24
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
- data/lib/kiosk/server/schema_definitions.rb +0 -351
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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 = "
|
|
19
|
+
# c.guc_namespace = "app"
|
|
20
20
|
|
|
21
|
-
# Postgres schema
|
|
22
|
-
|
|
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 `
|
|
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 `
|
|
26
|
-
|
|
27
|
-
type: '
|
|
28
|
-
description: '
|
|
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
|
|
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(:
|
|
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
|
-
# `
|
|
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::
|
|
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
|
|
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
|
|
75
|
-
# each `{qty:, price_cents:, ...}`, which sum to the total.
|
|
76
|
-
def
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
12
|
-
# `requested_role` always comes from a human's identity, never the
|
|
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 [
|
|
15
|
-
def bind!(
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
65
|
-
|
|
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 =
|
|
62
|
+
transition = client.user_id.to_s != user_id.to_s
|
|
70
63
|
conn.transaction do
|
|
71
|
-
Rows.rebind(conn, config.schema,
|
|
72
|
-
|
|
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
|
|
76
|
-
revoke_tokens(config,
|
|
77
|
-
|
|
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
|
-
#
|
|
80
|
+
# §6.3: the tokens of the previous holder stop.
|
|
81
81
|
def revoke_tokens(config, agent_id)
|
|
82
|
-
|
|
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}.
|
|
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}.
|
|
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
|
|
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:
|
|
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
|
-
|
|
13
|
-
return nil if
|
|
12
|
+
token = Dpop.token(authorization_for(request))
|
|
13
|
+
return nil if token.nil?
|
|
14
14
|
|
|
15
|
-
claims = verified_claims(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
62
|
-
row =
|
|
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
|
|
67
|
+
row
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
# `
|
|
69
|
-
def
|
|
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 #{
|
|
72
|
-
"Kiosk agent #{
|
|
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
|
|
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
|
|
79
|
+
# The query string's shape: what Rack can decode and the protocol accepts.
|
|
80
80
|
module QueryShape
|
|
81
81
|
module_function
|
|
82
82
|
|