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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The accounts that the engine opened for assistants at registration, in
|
|
6
|
+
# `assistant_accounts`. The first binding of the assistant merges its
|
|
7
|
+
# assistant account into the human's account: from then on the spend of
|
|
8
|
+
# the human's account includes the settlements and visits of the assistant
|
|
9
|
+
# account. Unlinking the assistant later keeps the merge.
|
|
10
|
+
module AssistantAccounts
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Records `user_id` as the account of a new assistant. An account that
|
|
14
|
+
# `assistant_creation` gave to an earlier key is recorded once.
|
|
15
|
+
def record!(user_id)
|
|
16
|
+
connection.exec_query(%(INSERT INTO "#{schema}".kiosk_assistant_accounts (user_id) VALUES ($1) ON CONFLICT DO NOTHING),
|
|
17
|
+
'Kiosk assistant account', [user_id])
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Merges the account `from` into the account `into` when `from` is an
|
|
22
|
+
# assistant account that is not merged yet. A binding moves an assistant
|
|
23
|
+
# to the account of the approving human, which is never an assistant
|
|
24
|
+
# account: so the account of one human never merges into the account of
|
|
25
|
+
# another, and merges do not form chains.
|
|
26
|
+
def merge!(from:, into:)
|
|
27
|
+
connection.exec_query(<<~SQL.squish, 'Kiosk assistant account merge', [from, into])
|
|
28
|
+
UPDATE "#{schema}".kiosk_assistant_accounts SET merged_into = $2, merged_at = now()
|
|
29
|
+
WHERE user_id = $1 AND merged_into IS NULL
|
|
30
|
+
SQL
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def connection = ::ActiveRecord::Base.lease_connection
|
|
35
|
+
def schema = Kiosk.configuration.schema
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
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,17 +3,17 @@
|
|
|
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
|
-
# the answer never tells a caller whether a key or a code exists. It
|
|
9
|
-
# the cost
|
|
10
|
-
# (deploy/README.md "Edge rate-limit").
|
|
8
|
+
# the answer never tells a caller whether a key or a code exists. It sets
|
|
9
|
+
# the cost of each request, not a rate. The shipped deploy has no edge rate
|
|
10
|
+
# limit (deploy/README.md "Edge rate-limit").
|
|
11
11
|
module AuthToll
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
14
|
def on?(config = Kiosk.configuration) = config.registration_pow_count.to_i.positive?
|
|
15
15
|
|
|
16
|
-
# `verb` and `body` make the fingerprint, so a proof
|
|
16
|
+
# `verb` and `body` make the fingerprint, so a proof clears the toll of this request only.
|
|
17
17
|
def gate(verb:, body:, pow:, config: Kiosk.configuration)
|
|
18
18
|
return unless on?(config)
|
|
19
19
|
|
|
@@ -26,7 +26,7 @@ module Kiosk
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def secret!(config)
|
|
29
|
-
unless defined?(::Kiosk::Reputation) && defined?(::Kiosk::Pow::Equihash)
|
|
29
|
+
unless defined?(::Kiosk::Reputation::Challenge) && defined?(::Kiosk::Pow::Equihash.params)
|
|
30
30
|
raise Errors::ConfigurationError,
|
|
31
31
|
'registration_pow_count > 0 requires kiosk-reputation and kiosk-pow-equihash. ' \
|
|
32
32
|
'Add both gems (and `require` them) to your app.'
|
|
@@ -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
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# The checkout an operator answers for an item to pay for: a JWT it signs
|
|
9
|
+
# EdDSA with its signing key. The assistant's checkout mandate names it by
|
|
10
|
+
# hash. EdDSA signatures are deterministic, so a random `jti` makes the hash
|
|
11
|
+
# of each checkout impossible to guess (AP2 v0.2, rainbow table attacks).
|
|
12
|
+
module Checkout
|
|
13
|
+
ALGORITHM = Eddsa::NAME
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param user_id the account the checkout is for; only that account can pay it
|
|
18
|
+
# @return [String] the compact JWT
|
|
19
|
+
def sign(user_id:, currency:, total_amount_cents:, line_items:)
|
|
20
|
+
signed({ iss: Kiosk.current_issuer, sub: user_id.to_s, iat: Time.now.to_i, jti: SecureRandom.urlsafe_base64(16),
|
|
21
|
+
merchant: merchant, currency: currency, total_amount_cents: total_amount_cents, line_items: line_items })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] `claims` as a compact JWT, signed EdDSA with the operator's signing key
|
|
25
|
+
def signed(claims)
|
|
26
|
+
key = Kiosk.configuration.signing_key
|
|
27
|
+
::JWT.encode(claims, key.key, ALGORITHM, { kid: key.kid, typ: 'JWT' })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The AP2 Merchant object: the payee the payment mandate names.
|
|
31
|
+
def merchant
|
|
32
|
+
issuer = Kiosk.current_issuer
|
|
33
|
+
{ id: issuer, name: Kiosk.configuration.owner[:name] || issuer, website: issuer }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] the claims of a checkout this origin signed, with its current or previous key, for this account
|
|
37
|
+
# @raise [Errors::Forbidden]
|
|
38
|
+
def verify(checkout_jwt, identity:)
|
|
39
|
+
claims = decode(checkout_jwt)
|
|
40
|
+
raise Errors::Forbidden.new('checkout is not from this origin', hint: ISSUER_HINT) unless claims['iss'] == Kiosk.current_issuer
|
|
41
|
+
return claims if claims['sub'] == identity.user_id.to_s
|
|
42
|
+
|
|
43
|
+
raise Errors::Forbidden.new('checkout is for another account', hint: 'pay only a checkout this operator answered to you')
|
|
44
|
+
rescue ::JWT::DecodeError
|
|
45
|
+
raise Errors::Forbidden.new('checkout_jwt signature invalid', hint: ISSUER_HINT)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The claims of a checkout that the current or the previous signing key signed.
|
|
49
|
+
def decode(checkout_jwt)
|
|
50
|
+
jwks = Jwks.build(keys: Kiosk.configuration.signing_keys)
|
|
51
|
+
::JWT.decode(checkout_jwt, nil, true, algorithms: [ALGORITHM]) { |header| Jwks.find(jwks, header['kid']) }.first
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ISSUER_HINT = 'send the checkout_jwt this operator answered, unchanged'
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# A registered OAuth client: one live row of the agents table at the origin
|
|
8
|
+
# of this request. Its `id` is both the `client_id` and the `agent_id`.
|
|
9
|
+
class Client < Data.define(:id, :user_id, :public_key_pem, :allowed_roles, :issued_at)
|
|
10
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
11
|
+
|
|
12
|
+
CLIENT_CREDENTIALS = 'client_credentials'
|
|
13
|
+
DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
14
|
+
|
|
15
|
+
AUTH_METHOD = 'private_key_jwt'
|
|
16
|
+
|
|
17
|
+
# The grant types this origin serves, so the grant types of every client.
|
|
18
|
+
def self.grant_types(config = Kiosk.configuration)
|
|
19
|
+
config.serve_account_binding ? [CLIENT_CREDENTIALS, DEVICE_CODE] : [CLIENT_CREDENTIALS]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# nil for an id that is not a UUID, so the database never sees it.
|
|
23
|
+
def self.find(id)
|
|
24
|
+
lookup('id = $1', id.to_s) if UUID.match?(id.to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.with_key(key)
|
|
28
|
+
lookup('public_key = $1', AssistantKey.to_pem(key))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.lookup(condition, value)
|
|
32
|
+
row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk client lookup', [value, Kiosk.current_issuer]).to_a.first
|
|
33
|
+
SELECT id, user_id, public_key, allowed_roles, extract(epoch FROM created_at)::bigint AS issued_at
|
|
34
|
+
FROM #{Kiosk.configuration.schema}.kiosk_agents
|
|
35
|
+
WHERE #{condition} AND issuer = $2 AND revoked_at IS NULL
|
|
36
|
+
LIMIT 1
|
|
37
|
+
SQL
|
|
38
|
+
row && new(id: row['id'], user_id: row['user_id'], public_key_pem: row['public_key'],
|
|
39
|
+
allowed_roles: row['allowed_roles'], issued_at: row['issued_at'].to_i)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :lookup
|
|
42
|
+
|
|
43
|
+
def key = AssistantKey.from_pem(public_key_pem)
|
|
44
|
+
|
|
45
|
+
def thumbprint = AssistantKey.thumbprint(key)
|
|
46
|
+
|
|
47
|
+
# The first role. `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
|
|
48
|
+
def role
|
|
49
|
+
roles = allowed_roles.is_a?(Array) ? allowed_roles : allowed_roles.to_s.delete('{}').split(',')
|
|
50
|
+
roles.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The RFC 7591 Section 3.2.1 client information response.
|
|
54
|
+
def information
|
|
55
|
+
{ client_id: id, client_id_issued_at: issued_at, jwks: { keys: [AssistantKey.to_jwk(key)] },
|
|
56
|
+
token_endpoint_auth_method: AUTH_METHOD, grant_types: self.class.grant_types,
|
|
57
|
+
dpop_bound_access_tokens: true }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'rails'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# Client authentication by `private_key_jwt`: an RFC 7523 client assertion
|
|
9
|
+
# that the registered key of the client signs. Each refusal says what to fix.
|
|
10
|
+
module ClientAssertion
|
|
11
|
+
TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
12
|
+
|
|
13
|
+
# Seconds: the longest life an assertion can have, and the clock difference accepted.
|
|
14
|
+
MAX_LIFETIME = 300
|
|
15
|
+
LEEWAY = 60
|
|
16
|
+
|
|
17
|
+
REQUIRED_CLAIMS = %w[iss sub aud exp jti].freeze
|
|
18
|
+
|
|
19
|
+
# A refused client assertion. The `/oauth/*` endpoints answer it as
|
|
20
|
+
# `invalid_client`, the other endpoints as `401 unauthenticated` with the
|
|
21
|
+
# `Kiosk-Client-Assertion` challenge.
|
|
22
|
+
class Refused < StandardError
|
|
23
|
+
attr_reader :hint
|
|
24
|
+
|
|
25
|
+
def initialize(message, hint:)
|
|
26
|
+
@hint = hint
|
|
27
|
+
super(message)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_oauth = OauthError.new(:invalid_client, "#{message}: #{hint}")
|
|
31
|
+
def to_problem = Errors::ClientAssertionRefused.new(message, hint: hint)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A verified assertion that is not yet spent.
|
|
35
|
+
Verified = Data.define(:client, :claims)
|
|
36
|
+
|
|
37
|
+
# Names no origin: a value that a response sends back is what a relaying server would exploit.
|
|
38
|
+
AUDIENCE_HINT = 'sign `aud` = the origin you connected to, taken from your own request URL, ' \
|
|
39
|
+
'never from a value that a response sends back'
|
|
40
|
+
|
|
41
|
+
CLAIMS_HINT = "the payload carries #{REQUIRED_CLAIMS.join(', ')}: `iss` and `sub` are your client_id, " \
|
|
42
|
+
'`aud` is the origin you connected to, `exp` is at most 300 seconds from now, ' \
|
|
43
|
+
'`jti` is a new unique id'.freeze
|
|
44
|
+
|
|
45
|
+
UNKNOWN_CLIENT_HINT = 'register your key at registration_url; the answer carries your client_id. ' \
|
|
46
|
+
'A client that the account holder unlinked is unknown'
|
|
47
|
+
|
|
48
|
+
SIGNATURE_HINT = 'a client assertion is a compact JWS that the private key of your registered key signs: ' \
|
|
49
|
+
'EdDSA for an Ed25519 key, ES256 for a P-256 key'
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Verifies and spends the assertion; answers its client.
|
|
54
|
+
# @raise [Refused]
|
|
55
|
+
def authenticate!(type, assertion, client_id: nil)
|
|
56
|
+
verified = verify(type, assertion, client_id: client_id)
|
|
57
|
+
spend!(verified)
|
|
58
|
+
verified.client
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Every check but the single use. `key` replaces the registered key: a
|
|
62
|
+
# rotation verifies the assertion of the new key with it.
|
|
63
|
+
# @raise [Refused]
|
|
64
|
+
def verify(type, assertion, client_id: nil, key: nil)
|
|
65
|
+
check_type!(type, assertion)
|
|
66
|
+
client = client_of(assertion)
|
|
67
|
+
claims = decode(assertion, key || registered_key(client))
|
|
68
|
+
check_claims!(claims, client_id)
|
|
69
|
+
Verified.new(client: client, claims: claims)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Claims the `jti` in the spent-id set until the assertion can no longer be accepted.
|
|
73
|
+
# @raise [Refused] when the `jti` is spent already
|
|
74
|
+
def spend!(verified)
|
|
75
|
+
id = "client_assertion:#{verified.client.id}:#{verified.claims['jti']}"
|
|
76
|
+
return if Kiosk.configuration.pow_spent_store.claim(id, verified.claims['exp'].to_i + LEEWAY)
|
|
77
|
+
|
|
78
|
+
refuse!('client assertion already used', 'sign a new client assertion with a new `jti` for each request')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_type!(type, assertion)
|
|
82
|
+
if assertion.to_s.empty?
|
|
83
|
+
refuse!('client authentication required',
|
|
84
|
+
"send client_assertion_type = #{TYPE} and client_assertion, a JWT that your registered key signs")
|
|
85
|
+
end
|
|
86
|
+
refuse!('unsupported client_assertion_type', "send client_assertion_type = #{TYPE}") unless type == TYPE
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The client the `sub` names. The signature is checked after this, with its key.
|
|
90
|
+
def client_of(assertion)
|
|
91
|
+
payload = ::JWT.decode(assertion.to_s, nil, false).first
|
|
92
|
+
Client.find(payload.is_a?(Hash) ? payload['sub'] : nil) || refuse!('unknown client', UNKNOWN_CLIENT_HINT)
|
|
93
|
+
rescue ::JWT::DecodeError
|
|
94
|
+
refuse!('client assertion is not a JWT', SIGNATURE_HINT)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A key that this operator accepts no longer, such as a P-256 key once ES256 is off.
|
|
98
|
+
def registered_key(client)
|
|
99
|
+
client.key
|
|
100
|
+
rescue Errors::BadRequest
|
|
101
|
+
refuse!('the registered key is not accepted here',
|
|
102
|
+
"this operator accepts #{AssistantKey.algorithms.join(' and ')} keys: register a new key at registration_url")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def decode(assertion, key)
|
|
106
|
+
::JWT.decode(assertion.to_s, key, true, algorithms: [AssistantKey.algorithm(key)],
|
|
107
|
+
required_claims: REQUIRED_CLAIMS, leeway: LEEWAY).first
|
|
108
|
+
rescue ::JWT::IncorrectAlgorithm
|
|
109
|
+
refuse!("client assertion alg is not #{AssistantKey.algorithm(key)}, the algorithm of your registered key", SIGNATURE_HINT)
|
|
110
|
+
rescue ::JWT::MissingRequiredClaim
|
|
111
|
+
refuse!('client assertion is missing a required claim', CLAIMS_HINT)
|
|
112
|
+
rescue ::JWT::ExpiredSignature
|
|
113
|
+
refuse!('client assertion expired', 'sign a new client assertion')
|
|
114
|
+
rescue ::JWT::DecodeError
|
|
115
|
+
refuse!('client assertion signature invalid', SIGNATURE_HINT)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_claims!(claims, client_id)
|
|
119
|
+
refuse!('client assertion iss is not its sub', CLAIMS_HINT) unless claims['iss'] == claims['sub']
|
|
120
|
+
refuse!('client_id is not the client assertion sub', CLAIMS_HINT) if client_id.present? && client_id != claims['sub']
|
|
121
|
+
check_audience!(claims['aud'])
|
|
122
|
+
return if claims['exp'].to_i <= Time.now.to_i + MAX_LIFETIME
|
|
123
|
+
|
|
124
|
+
refuse!('client assertion lives too long', "set `exp` at most #{MAX_LIFETIME} seconds after now")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `aud` names this origin as its only value (RFC 7523bis Section 4).
|
|
128
|
+
def check_audience!(aud)
|
|
129
|
+
issuer = Kiosk.current_issuer
|
|
130
|
+
return if Array(aud) == [issuer]
|
|
131
|
+
|
|
132
|
+
log_audience_mismatch(aud, issuer)
|
|
133
|
+
refuse!('client assertion audience mismatch', AUDIENCE_HINT)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Operator log only, because the answer must not name an origin: the
|
|
137
|
+
# caller signed a wrong value, or this deployment does not serve the origin it reached.
|
|
138
|
+
def log_audience_mismatch(aud, issuer)
|
|
139
|
+
message = "[kiosk] client assertion audience mismatch: the caller signed aud=#{aud.inspect}, " \
|
|
140
|
+
"the issuer for this request is #{issuer.inspect}. If the signed value is an origin " \
|
|
141
|
+
'your assistants actually reach, list it in `c.issuer` or `c.additional_origins`.'
|
|
142
|
+
logger = ::Rails.logger
|
|
143
|
+
logger ? logger.warn(message) : warn(message)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refuse!(message, hint)
|
|
147
|
+
raise Refused.new(message, hint: hint)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private_class_method :check_type!, :client_of, :registered_key, :decode, :check_claims!, :check_audience!,
|
|
151
|
+
:log_audience_mismatch, :refuse!
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'active_support/core_ext/string/inflections' # String#constantize
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# RFC 7591 open registration of an AI assistant's public key, with no human:
|
|
9
|
+
# creates the assistant account and its client. A key with a live client
|
|
10
|
+
# gets that client again, so an assistant that lost its client_id recovers it.
|
|
11
|
+
module ClientRegistration
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param metadata [Hash] the parsed request body
|
|
15
|
+
# @return [Client]
|
|
16
|
+
# @raise [OauthError] `invalid_client_metadata`
|
|
17
|
+
def call(metadata, pow: nil)
|
|
18
|
+
config = Kiosk.configuration
|
|
19
|
+
role = registration_role(config)
|
|
20
|
+
key = checked_key(metadata)
|
|
21
|
+
|
|
22
|
+
AuthToll.gate(verb: 'oauth/register', body: { key: AssistantKey.thumbprint(key) }, pow: pow, config: config)
|
|
23
|
+
Client.with_key(key) || register(config, key, role)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def checked_key(metadata)
|
|
27
|
+
invalid!('the request body must be a JSON object') unless metadata.is_a?(Hash)
|
|
28
|
+
metadata = metadata.transform_keys(&:to_s)
|
|
29
|
+
method = metadata['token_endpoint_auth_method']
|
|
30
|
+
invalid!("token_endpoint_auth_method must be #{Client::AUTH_METHOD}") unless [nil, Client::AUTH_METHOD].include?(method)
|
|
31
|
+
check_grant_types!(metadata['grant_types'])
|
|
32
|
+
AssistantKey.from_jwks(metadata['jwks'])
|
|
33
|
+
rescue Errors::BadRequest => e
|
|
34
|
+
invalid!("jwks: #{e.message}; #{e.hint}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_grant_types!(grant_types)
|
|
38
|
+
return if grant_types.nil?
|
|
39
|
+
return if grant_types.is_a?(Array) && (grant_types - Client.grant_types).empty?
|
|
40
|
+
|
|
41
|
+
invalid!("grant_types must be a list of the grant types this origin serves: #{Client.grant_types.join(', ')}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The operator's `assistant_creation` factory, else a bare `user_model.create!`.
|
|
45
|
+
def create_assistant_account(config, pem)
|
|
46
|
+
return config.user_model.constantize.create!.id unless config.assistant_creation
|
|
47
|
+
|
|
48
|
+
id = config.assistant_creation.call(pem)
|
|
49
|
+
return id unless id.nil?
|
|
50
|
+
|
|
51
|
+
raise Errors::ConfigurationError,
|
|
52
|
+
'config.assistant_creation returned nil. The block must create the ' \
|
|
53
|
+
'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
|
|
54
|
+
'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The role is the server's choice, never the assistant's.
|
|
58
|
+
def registration_role(config)
|
|
59
|
+
role = config.registration_role&.to_s
|
|
60
|
+
return if role.blank?
|
|
61
|
+
return role if config.roles.map(&:to_s).include?(role)
|
|
62
|
+
|
|
63
|
+
raise Errors::ConfigurationError,
|
|
64
|
+
"registration_role #{role.inspect} is not among configured roles " \
|
|
65
|
+
"#{config.roles.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The live unique index on (issuer, public_key) picks the winner of a race.
|
|
69
|
+
def register(config, key, role)
|
|
70
|
+
pem = AssistantKey.to_pem(key)
|
|
71
|
+
conn = ::ActiveRecord::Base.lease_connection
|
|
72
|
+
conn.transaction(requires_new: true) do
|
|
73
|
+
account = create_assistant_account(config, pem)
|
|
74
|
+
AssistantAccounts.record!(account)
|
|
75
|
+
insert_agent(conn, config, [account, pem], role)
|
|
76
|
+
end
|
|
77
|
+
Client.with_key(key)
|
|
78
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
79
|
+
Client.with_key(key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# No role is the empty array: `allowed_roles` is NOT NULL.
|
|
83
|
+
def insert_agent(conn, config, binds, role)
|
|
84
|
+
allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
|
|
85
|
+
conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, Kiosk.current_issuer, *role_binds])
|
|
86
|
+
INSERT INTO #{config.schema}.kiosk_agents (user_id, public_key, issuer, allowed_roles)
|
|
87
|
+
VALUES ($1, $2, $3, #{allowed_roles_sql})
|
|
88
|
+
SQL
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def invalid!(description)
|
|
92
|
+
raise OauthError.new(:invalid_client_metadata, description)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :checked_key, :check_grant_types!, :registration_role, :register, :insert_agent, :invalid!
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -14,7 +14,7 @@ module Kiosk
|
|
|
14
14
|
# The leased connection: the cap is read inside the transaction that charges.
|
|
15
15
|
row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk spending cap', [agent_id]).to_a.first
|
|
16
16
|
SELECT spending_cap_cents, spending_cap_currency
|
|
17
|
-
FROM "#{Kiosk.configuration.schema}".
|
|
17
|
+
FROM "#{Kiosk.configuration.schema}".kiosk_agents
|
|
18
18
|
WHERE id = $1 AND revoked_at IS NULL
|
|
19
19
|
SQL
|
|
20
20
|
return nil if row.nil? || row['spending_cap_cents'].nil?
|