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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +63 -41
  4. data/app/controllers/kiosk/server/auth_controller.rb +18 -62
  5. data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
  6. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  7. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  8. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
  9. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  10. data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
  11. data/app/models/kiosk/checkout_mandate.rb +22 -0
  12. data/app/models/kiosk/settlement.rb +1 -1
  13. data/lib/generators/kiosk/install/install_generator.rb +0 -5
  14. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
  15. data/lib/kiosk/payable.rb +19 -25
  16. data/lib/kiosk/server/account_binding.rb +19 -58
  17. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  18. data/lib/kiosk/server/agent_rotation.rb +62 -0
  19. data/lib/kiosk/server/assistant_key.rb +86 -0
  20. data/lib/kiosk/server/auth_toll.rb +1 -1
  21. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  22. data/lib/kiosk/server/checkout.rb +57 -0
  23. data/lib/kiosk/server/client.rb +61 -0
  24. data/lib/kiosk/server/client_assertion.rb +153 -0
  25. data/lib/kiosk/server/client_registration.rb +94 -0
  26. data/lib/kiosk/server/configuration_extension.rb +56 -44
  27. data/lib/kiosk/server/device_code_grant.rb +38 -69
  28. data/lib/kiosk/server/dpop.rb +121 -0
  29. data/lib/kiosk/server/eddsa.rb +38 -0
  30. data/lib/kiosk/server/engine.rb +14 -11
  31. data/lib/kiosk/server/errors.rb +7 -0
  32. data/lib/kiosk/server/events_connection.rb +15 -5
  33. data/lib/kiosk/server/executor/repeat.rb +13 -12
  34. data/lib/kiosk/server/executor/spending_cap.rb +25 -26
  35. data/lib/kiosk/server/executor.rb +82 -101
  36. data/lib/kiosk/server/identity_resolution.rb +12 -3
  37. data/lib/kiosk/server/jwk.rb +83 -0
  38. data/lib/kiosk/server/jwks.rb +6 -0
  39. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  40. data/lib/kiosk/server/kyc_verifier.rb +2 -2
  41. data/lib/kiosk/server/link_code.rb +9 -18
  42. data/lib/kiosk/server/linked_assistants.rb +1 -1
  43. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  44. data/lib/kiosk/server/oauth_answers.rb +30 -0
  45. data/lib/kiosk/server/oauth_error.rb +30 -0
  46. data/lib/kiosk/server/open_api.rb +22 -20
  47. data/lib/kiosk/server/payment_claim.rb +44 -75
  48. data/lib/kiosk/server/receipts.rb +29 -0
  49. data/lib/kiosk/server/request_validation.rb +1 -2
  50. data/lib/kiosk/server/revocation_stores.rb +8 -0
  51. data/lib/kiosk/server/schema_definitions.rb +33 -64
  52. data/lib/kiosk/server/schemas/auth.schema.json +368 -57
  53. data/lib/kiosk/server/schemas/binding.schema.json +23 -58
  54. data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
  55. data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
  56. data/lib/kiosk/server/sd_jwt.rb +104 -0
  57. data/lib/kiosk/server/signing_key.rb +18 -52
  58. data/lib/kiosk/server/verb_wire.rb +1 -1
  59. data/lib/kiosk/server/version.rb +1 -1
  60. data/lib/kiosk/server/well_known.rb +268 -204
  61. data/lib/kiosk/server/wire_rendering.rb +1 -1
  62. data/lib/kiosk/server.rb +2 -0
  63. data/lib/kiosk/skill_link.rb +6 -2
  64. data/listen.py +38 -8
  65. metadata +26 -17
  66. data/app/models/kiosk/cart_mandate.rb +0 -21
  67. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  68. data/lib/kiosk/server/agent_login.rb +0 -49
  69. data/lib/kiosk/server/agent_registration.rb +0 -88
  70. data/lib/kiosk/server/auth_challenge.rb +0 -33
  71. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  72. data/lib/kiosk/server/pop_verifier.rb +0 -87
@@ -4,55 +4,56 @@ require 'action_controller'
4
4
 
5
5
  module Kiosk
6
6
  module Server
7
- # POST <endpoint>/oauth/token: the assistant polls the claim ceremony
8
- # (RFC 8628 §3.4). Only the device_code grant is served.
7
+ # POST <endpoint>/oauth/token (RFC 6749 Section 3.2): a registered client
8
+ # authenticates with a client assertion (RFC 7523) and a DPoP proof of its
9
+ # registered key (RFC 9449), and gets an access token bound to that key.
10
+ # The client_credentials grant is always served; the device-code grant
11
+ # (RFC 8628 Section 3.4) only where the origin serves account binding.
9
12
  class OauthTokenController < ::ActionController::API
10
- include BindingModuleGate
11
13
  include AuthController::Answers
12
14
  include AuthController::Proofs
13
-
14
- prepend_before_action :refuse_unserved_binding
15
-
16
- UNSUPPORTED_GRANT = 'only the device_code grant is served here — ' \
17
- 'kiosk-pop (POST /auth/login) is the token-refresh path'
15
+ include OauthAnswers
18
16
 
19
17
  def create
20
18
  case params[:grant_type].to_s
21
- when DeviceCodeGrant::GRANT_TYPE then handle_device_code_grant
22
- when '' then render_oauth_error(:invalid_request, 'grant_type required', status: 400)
23
- else render_oauth_error(:unsupported_grant_type, UNSUPPORTED_GRANT, status: 400)
19
+ when Client::CLIENT_CREDENTIALS then render_token(authenticated_client)
20
+ when Client::DEVICE_CODE then device_code_grant
21
+ when '' then raise OauthError.new(:invalid_request, 'grant_type required')
22
+ else raise OauthError.new(:unsupported_grant_type, unsupported_grant)
24
23
  end
24
+ rescue OauthError => e
25
+ render_oauth_error(e)
26
+ rescue Errors::Base => e
27
+ render_error(e)
25
28
  end
26
29
 
27
30
  private
28
31
 
29
- # The auth toll answers with its own problem documents (§10); every other refusal is OAuth's.
30
- def handle_device_code_grant
31
- result = DeviceCodeGrant.exchange(device_code: params[:device_code].to_s, signed: params[:signed], pow: proofs)
32
- Kiosk::Server::Headers.add_to(response.headers)
33
- return render json: token_body(result) if result[:ok]
32
+ # The toll comes before the client authenticates and before the code is read.
33
+ def device_code_grant
34
+ device_code = params[:device_code].to_s
35
+ raise OauthError.new(:unsupported_grant_type, unsupported_grant) unless Kiosk.configuration.serve_account_binding
36
+ raise OauthError.new(:invalid_request, 'device_code parameter required') if device_code.empty?
34
37
 
35
- render_grant_failure(result)
36
- rescue Errors::Base => e
37
- render_error(e)
38
+ DeviceCodeGrant.pay_toll!(device_code, proofs)
39
+ render_token(DeviceCodeGrant.exchange(device_code: device_code, client: authenticated_client))
38
40
  end
39
41
 
40
- # RFC 6749 §5.2: invalid_client — the possession proof failed —
41
- # is the one error the server SHOULD signal with 401.
42
- def render_grant_failure(result)
43
- status = result[:error] == 'invalid_client' ? 401 : 400
44
- render json: { error: result[:error], error_description: result[:description] }, status: status
45
- end
42
+ def unsupported_grant = "the grant types served here are #{Client.grant_types.join(', ')}"
46
43
 
47
- def token_body(result)
48
- body = { access_token: result[:access_token], token_type: result[:token_type], expires_in: result[:expires_in] }
49
- body[:scope] = result[:scope] if result[:scope]
50
- body
44
+ # The client assertion, then the DPoP proof of the registered key.
45
+ def authenticated_client
46
+ client = authenticate_client!
47
+ Dpop.verify_token_request!(request.headers['DPoP'], jkt: client.thumbprint, url: "#{Kiosk.current_issuer}#{request.path}")
48
+ client
51
49
  end
52
50
 
53
- def render_oauth_error(code, description, status:)
54
- Kiosk::Server::Headers.add_to(response.headers)
55
- render json: { error: code.to_s, error_description: description }, status: status
51
+ # RFC 6749 Section 5.1. The scope is the role of the client.
52
+ def render_token(client)
53
+ token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: client.id, role: client.role)
54
+ body = { access_token: token, token_type: Dpop::SCHEME, expires_in: Kiosk.configuration.access_token_ttl }
55
+ body[:scope] = client.role if client.role
56
+ render_oauth(body)
56
57
  end
57
58
  end
58
59
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ # The checkout mandate the engine records when it settles a `pay`: the line
5
+ # items of the operator's checkout that the assistant agreed to pay for.
6
+ # Read-only for the operator.
7
+ class CheckoutMandate < Server::ApplicationRecord
8
+ def self.table_name = "#{Kiosk.configuration.schema}.checkout_mandates"
9
+
10
+ has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :checkout_mandate
11
+
12
+ # Checkout mandates with a line item carrying these attributes, by jsonb
13
+ # containment: `referencing(order_id: id)` matches `[{"order_id": id, "qty": 2}]`.
14
+ # The values are quoted by the adapter, never interpolated.
15
+ scope :referencing, lambda { |**attributes|
16
+ where(Arel::Nodes::InfixOperation.new(
17
+ '@>', arel_table[:line_items],
18
+ Arel::Nodes.build_quoted([attributes.transform_values(&:to_s)].to_json)
19
+ ))
20
+ }
21
+ end
22
+ end
@@ -7,6 +7,6 @@ module Kiosk
7
7
 
8
8
  def self.table_name = "#{Kiosk.configuration.schema}.settlements"
9
9
 
10
- belongs_to :cart_mandate, class_name: 'Kiosk::CartMandate', inverse_of: :settlements
10
+ belongs_to :checkout_mandate, class_name: 'Kiosk::CheckoutMandate', inverse_of: :settlements
11
11
  end
12
12
  end
@@ -106,11 +106,6 @@ module Kiosk
106
106
  'db/migrate/create_kiosk_revocations.rb'
107
107
  end
108
108
 
109
- def create_auth_challenges_migration
110
- migration_template 'create_kiosk_auth_challenges.rb.tt',
111
- 'db/migrate/create_kiosk_auth_challenges.rb'
112
- end
113
-
114
109
  def create_visits_migration
115
110
  migration_template 'create_kiosk_visits.rb.tt',
116
111
  'db/migrate/create_kiosk_visits.rb'
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # checkout_mandates, payment_mandates, settlements.
5
+ # See the Payment (AP2 mandates) section of the spec.
6
6
  class CreateKioskMandates < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
7
7
  def up
8
8
  execute <<~'SQL'
@@ -12,10 +12,9 @@ class CreateKioskMandates < ActiveRecord::Migration[ActiveRecord::Migration.curr
12
12
 
13
13
  def down
14
14
  # Reverse-FK order: payment_mandates and settlements both reference
15
- # cart_mandates, so both drop before it; cart_mandates before intent_mandates.
15
+ # checkout_mandates, so both drop before it.
16
16
  execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".payment_mandates)
17
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)
18
+ execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".checkout_mandates)
20
19
  end
21
20
  end
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 on the wire, 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,27 @@
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.
8
7
  module AccountBinding
9
8
  module_function
10
9
 
11
- # Call only after possession of `public_key_pem` has been proven.
10
+ # Call only after the client has authenticated.
12
11
  # `requested_role` always comes from a human's identity, never the wire.
13
12
  #
14
- # @return [Hash] { agent_id:, user_id:, access_token:, fresh: }
15
- def bind!(public_key_pem:, user_id:, requested_role: nil)
13
+ # @return [Client] the client under its new holder
14
+ def bind!(agent_id:, user_id:, requested_role: nil)
16
15
  config = Kiosk.configuration
17
- pem = public_key_pem.to_s.strip
18
16
  raise ArgumentError, 'user_id required' if user_id.nil? || user_id.to_s.empty?
19
17
 
18
+ client = Client.find(agent_id) || raise(ArgumentError, "no live client #{agent_id.inspect} at this origin")
20
19
  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)
20
+ rebind(::ActiveRecord::Base.lease_connection, config, client, user_id, requested_role)
21
+ Client.find(agent_id)
28
22
  end
29
23
 
30
24
  # Revokes `agent_id`'s binding, which must belong to `user_id`; its tokens
31
- # stop verifying and its `/auth/login` is denied.
25
+ # stop verifying and the token endpoint refuses it.
32
26
  #
33
27
  # @raise [Errors::NotFound] when no live agent row matches the pair.
34
28
  def unlink!(agent_id:, user_id:)
@@ -59,27 +53,24 @@ module Kiosk
59
53
  class << self
60
54
  private
61
55
 
62
- # Known key: remap the holder and role, keep agent_id and reputation.
56
+ # Remaps the holder and role, keeps agent_id and reputation.
63
57
  # 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)
58
+ def rebind(conn, config, client, user_id, requested_role)
59
+ role = resolved_role(config, requested_role)
68
60
  # The same holder still gets the role remap and revocation; only the hook is skipped.
69
- transition = previous.to_s != user_id.to_s
61
+ transition = client.user_id.to_s != user_id.to_s
70
62
  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
63
+ Rows.rebind(conn, config.schema, client.id, user_id, role)
64
+ claimed(config, client.id, client.user_id, user_id) if transition && config.assistant_claimed
73
65
  end
74
66
  # 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 }
67
+ # watermark, so a token minted after this survives it.
68
+ revoke_tokens(config, client.id)
78
69
  end
79
70
 
80
- # Next second: JWT `iat` is whole seconds and the check is `iat < watermark` (§6.3).
71
+ # §6.3: the tokens of the previous holder stop.
81
72
  def revoke_tokens(config, agent_id)
82
- config.revocation_store.revoke_all(agent_id, at: Time.now.to_i + 1)
73
+ RevocationStores.revoke_issued(agent_id, store: config.revocation_store)
83
74
  end
84
75
 
85
76
  def claimed(config, agent_id, previous, user_id)
@@ -87,13 +78,6 @@ module Kiosk
87
78
  config.assistant_claimed.call(agent: agent_id, from: accounts.find(previous), to: accounts.find(user_id))
88
79
  end
89
80
 
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
81
  def not_linked
98
82
  Errors::NotFound.new(
99
83
  'no linked assistant account with this agent_id',
@@ -130,24 +114,12 @@ module Kiosk
130
114
  logger = ::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
131
115
  logger ? logger.warn(message) : Kernel.warn(message)
132
116
  end
133
-
134
- def issue_token(agent_id, role)
135
- AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
136
- end
137
117
  end
138
118
 
139
119
  # The agents-table statements a binding runs.
140
120
  module Rows
141
121
  module_function
142
122
 
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
123
  def revoke_owned(conn, schema, agent_id, user_id)
152
124
  conn.exec_query(<<~SQL.squish, 'Kiosk agent unlink', [agent_id, user_id, Kiosk.current_issuer]).to_a.first
153
125
  UPDATE #{schema}.agents
@@ -170,17 +142,6 @@ module Kiosk
170
142
  WHERE id = $2
171
143
  SQL
172
144
  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
145
  end
185
146
  private_constant :Rows
186
147
  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}.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}.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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # The public key of an AI assistant: the key it registers, signs its client
8
+ # assertions, DPoP proofs and mandates with, and binds its tokens to. An
9
+ # Ed25519 key signs EdDSA. A P-256 key signs ES256 where the operator
10
+ # accepts ES256 (`c.assistant_algorithms`).
11
+ module AssistantKey
12
+ # The members of a JWK that only a private key has.
13
+ PRIVATE_MEMBERS = %w[d].freeze
14
+
15
+ module_function
16
+
17
+ # The algorithms this operator accepts, EdDSA first. Client assertions,
18
+ # DPoP proofs, mandates and every metadata list read this list.
19
+ def algorithms = Kiosk.configuration.assistant_algorithms
20
+
21
+ def hint
22
+ keys = ['an Ed25519 key as an RFC 8037 JWK {"kty":"OKP","crv":"Ed25519","x":"…"}']
23
+ keys << 'a P-256 key as an RFC 7518 JWK {"kty":"EC","crv":"P-256","x":"…","y":"…"}' if algorithms.include?('ES256')
24
+ "send one public key: #{keys.join(', or ')}, with no private member"
25
+ end
26
+
27
+ # The key of an RFC 7517 JWK Set that holds exactly one public key.
28
+ # @raise [Errors::BadRequest] naming what is wrong
29
+ def from_jwks(jwks)
30
+ keys = jwks.is_a?(Hash) ? (jwks['keys'] || jwks[:keys]) : nil
31
+ refuse!('jwks must be a JWK Set with exactly one key') unless keys.is_a?(Array) && keys.size == 1
32
+
33
+ from_jwk(keys.first)
34
+ end
35
+
36
+ # @raise [Errors::BadRequest] naming what is wrong
37
+ def from_jwk(jwk)
38
+ refuse!('the key is not a JWK object') unless jwk.is_a?(Hash)
39
+ jwk = jwk.transform_keys(&:to_s)
40
+ refuse!('the key carries a private member') if jwk.keys.intersect?(PRIVATE_MEMBERS)
41
+ refuse!("the key is not an #{curves} key") unless key_types.include?(jwk['kty'])
42
+
43
+ accepted(Jwk.import(jwk))
44
+ rescue ArgumentError => e
45
+ refuse!(e.message)
46
+ end
47
+
48
+ # The key of a row of the agents table.
49
+ # @raise [Errors::BadRequest] when this operator does not accept the key
50
+ def from_pem(pem)
51
+ accepted(OpenSSL::PKey.read(pem.to_s))
52
+ rescue OpenSSL::PKey::PKeyError
53
+ refuse!('the key is not a valid public key')
54
+ end
55
+
56
+ # The form the agents table stores.
57
+ def to_pem(key) = key.public_to_pem.strip
58
+
59
+ # RFC 7517: the public members only.
60
+ def to_jwk(key) = Jwk.public_members(key)
61
+
62
+ # RFC 7638.
63
+ def thumbprint(key) = Jwk.thumbprint(key)
64
+
65
+ # The one algorithm `key` signs with.
66
+ def algorithm(key) = Jwk.algorithm(key)
67
+
68
+ # `OKP`, and `EC` where ES256 is accepted.
69
+ def key_types = algorithms.map { Jwk::ALGORITHMS.key(it) }
70
+
71
+ def curves = key_types.map { Jwk::CURVES[it] }.join(' or ')
72
+
73
+ def accepted(key)
74
+ refuse!('the key is a private key') if Jwk.private?(key)
75
+ refuse!("the key is not an #{curves} key") unless algorithms.include?(algorithm(key))
76
+ key
77
+ end
78
+
79
+ def refuse!(detail)
80
+ raise Errors::BadRequest.new(detail, hint: hint)
81
+ end
82
+
83
+ private_class_method :key_types, :curves, :accepted, :refuse!
84
+ end
85
+ end
86
+ end
@@ -3,7 +3,7 @@
3
3
  module Kiosk
4
4
  module Server
5
5
  # Optional proof-of-work toll on the auth requests that come before a
6
- # token: `POST /auth/register`, the link-code claim `POST /auth/claim`, and
6
+ # token: `POST /oauth/register`, the link-code claim `POST /auth/claim`, and
7
7
  # the first token poll of each device code. It runs before any lookup, so
8
8
  # the answer never tells a caller whether a key or a code exists. It bounds
9
9
  # the cost per request, not the rate: an edge rate limit is required
@@ -7,7 +7,7 @@ module Kiosk
7
7
  # `/oauth/*` it is the one problem document served instead of an OAuth error.
8
8
  module BindingModuleGate
9
9
  DETAIL = 'this operator does not serve the account binding module'
10
- HINT = "register a fresh assistant account at this origin's register_url instead"
10
+ HINT = "use this origin with a client of your own: register it at this origin's registration_url"
11
11
 
12
12
  private
13
13