kiosk-server 0.5.20 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
|
@@ -4,55 +4,56 @@ require 'action_controller'
|
|
|
4
4
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
|
-
# POST <endpoint>/oauth/token
|
|
8
|
-
# (RFC
|
|
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
|
|
22
|
-
when
|
|
23
|
-
|
|
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
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 :
|
|
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
|
-
#
|
|
5
|
-
# See the Payment (AP2
|
|
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
|
-
#
|
|
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] %>".
|
|
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 `
|
|
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 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(:
|
|
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,27 @@
|
|
|
2
2
|
|
|
3
3
|
module Kiosk
|
|
4
4
|
module Server
|
|
5
|
-
# Binds a
|
|
6
|
-
#
|
|
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
|
|
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 [
|
|
15
|
-
def bind!(
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
65
|
-
|
|
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 =
|
|
61
|
+
transition = client.user_id.to_s != user_id.to_s
|
|
70
62
|
conn.transaction do
|
|
71
|
-
Rows.rebind(conn, config.schema,
|
|
72
|
-
claimed(config,
|
|
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
|
|
76
|
-
revoke_tokens(config,
|
|
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
|
-
#
|
|
71
|
+
# §6.3: the tokens of the previous holder stop.
|
|
81
72
|
def revoke_tokens(config, agent_id)
|
|
82
|
-
|
|
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:
|
|
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}.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 /
|
|
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 = "
|
|
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
|
|