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
|
@@ -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}.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,153 @@
|
|
|
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`.
|
|
21
|
+
class Refused < StandardError
|
|
22
|
+
attr_reader :hint
|
|
23
|
+
|
|
24
|
+
def initialize(message, hint:)
|
|
25
|
+
@hint = hint
|
|
26
|
+
super(message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_oauth = OauthError.new(:invalid_client, "#{message}: #{hint}")
|
|
30
|
+
def to_problem = Errors::Unauthenticated.new(message, hint: hint)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A verified assertion that is not yet spent.
|
|
34
|
+
Verified = Data.define(:client, :claims)
|
|
35
|
+
|
|
36
|
+
# Names no origin: a value that a response sends back is what a relaying server would exploit.
|
|
37
|
+
AUDIENCE_HINT = 'sign `aud` = the origin you connected to, taken from your own request URL, ' \
|
|
38
|
+
'never from a value that a response sends back'
|
|
39
|
+
|
|
40
|
+
CLAIMS_HINT = "the payload carries #{REQUIRED_CLAIMS.join(', ')}: `iss` and `sub` are your client_id, " \
|
|
41
|
+
'`aud` is the origin you connected to, `exp` is at most 300 seconds from now, ' \
|
|
42
|
+
'`jti` is a new unique id'.freeze
|
|
43
|
+
|
|
44
|
+
UNKNOWN_CLIENT_HINT = 'register your key at registration_url; the answer carries your client_id. ' \
|
|
45
|
+
'A client that the account holder unlinked is unknown'
|
|
46
|
+
|
|
47
|
+
SIGNATURE_HINT = 'a client assertion is a compact JWS that the private key of your registered key signs: ' \
|
|
48
|
+
'EdDSA for an Ed25519 key, ES256 for a P-256 key'
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# Verifies and spends the assertion; answers its client.
|
|
53
|
+
# @raise [Refused]
|
|
54
|
+
def authenticate!(type, assertion, client_id: nil)
|
|
55
|
+
verified = verify(type, assertion, client_id: client_id)
|
|
56
|
+
spend!(verified)
|
|
57
|
+
verified.client
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Every check but the single use. `key` replaces the registered key: a
|
|
61
|
+
# rotation verifies the assertion of the new key with it.
|
|
62
|
+
# @raise [Refused]
|
|
63
|
+
def verify(type, assertion, client_id: nil, key: nil)
|
|
64
|
+
check_type!(type, assertion)
|
|
65
|
+
client = client_of(assertion)
|
|
66
|
+
claims = decode(assertion, key || registered_key(client))
|
|
67
|
+
check_claims!(claims, client_id)
|
|
68
|
+
Verified.new(client: client, claims: claims)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Claims the `jti` in the spent-id set until the assertion can no longer be accepted.
|
|
72
|
+
# @raise [Refused] when the `jti` is spent already
|
|
73
|
+
def spend!(verified)
|
|
74
|
+
id = "client_assertion:#{verified.client.id}:#{verified.claims['jti']}"
|
|
75
|
+
return if Kiosk.configuration.pow_spent_store.claim(id, verified.claims['exp'].to_i + LEEWAY)
|
|
76
|
+
|
|
77
|
+
refuse!('client assertion already used', 'sign a new client assertion with a new `jti` for each request')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def check_type!(type, assertion)
|
|
81
|
+
if assertion.to_s.empty?
|
|
82
|
+
refuse!('client authentication required',
|
|
83
|
+
"send client_assertion_type = #{TYPE} and client_assertion, a JWT that your registered key signs")
|
|
84
|
+
end
|
|
85
|
+
refuse!('unsupported client_assertion_type', "send client_assertion_type = #{TYPE}") unless type == TYPE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The client the `sub` names. The signature is checked after this, with its key.
|
|
89
|
+
def client_of(assertion)
|
|
90
|
+
payload = ::JWT.decode(assertion.to_s, nil, false).first
|
|
91
|
+
Client.find(payload.is_a?(Hash) ? payload['sub'] : nil) || refuse!('unknown client', UNKNOWN_CLIENT_HINT)
|
|
92
|
+
rescue ::JWT::DecodeError
|
|
93
|
+
refuse!('client assertion is not a JWT', SIGNATURE_HINT)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A key that this operator accepts no longer, such as a P-256 key once ES256 is off.
|
|
97
|
+
def registered_key(client)
|
|
98
|
+
client.key
|
|
99
|
+
rescue Errors::BadRequest
|
|
100
|
+
refuse!('the registered key is not accepted here',
|
|
101
|
+
"this operator accepts #{AssistantKey.algorithms.join(' and ')} keys: register a new key at registration_url")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def decode(assertion, key)
|
|
105
|
+
::JWT.decode(assertion.to_s, key, true, algorithms: [AssistantKey.algorithm(key)],
|
|
106
|
+
required_claims: REQUIRED_CLAIMS, leeway: LEEWAY).first
|
|
107
|
+
rescue ::JWT::IncorrectAlgorithm
|
|
108
|
+
refuse!("client assertion alg is not #{AssistantKey.algorithm(key)}, the algorithm of your registered key", SIGNATURE_HINT)
|
|
109
|
+
rescue ::JWT::MissingRequiredClaim
|
|
110
|
+
refuse!('client assertion is missing a required claim', CLAIMS_HINT)
|
|
111
|
+
rescue ::JWT::ExpiredSignature
|
|
112
|
+
refuse!('client assertion expired', 'sign a new client assertion')
|
|
113
|
+
rescue ::JWT::DecodeError
|
|
114
|
+
refuse!('client assertion signature invalid', SIGNATURE_HINT)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def check_claims!(claims, client_id)
|
|
118
|
+
refuse!('client assertion iss is not its sub', CLAIMS_HINT) unless claims['iss'] == claims['sub']
|
|
119
|
+
refuse!('client_id is not the client assertion sub', CLAIMS_HINT) if client_id.present? && client_id != claims['sub']
|
|
120
|
+
check_audience!(claims['aud'])
|
|
121
|
+
return if claims['exp'].to_i <= Time.now.to_i + MAX_LIFETIME
|
|
122
|
+
|
|
123
|
+
refuse!('client assertion lives too long', "set `exp` at most #{MAX_LIFETIME} seconds after now")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `aud` names this origin as its only value (RFC 7523bis Section 4).
|
|
127
|
+
def check_audience!(aud)
|
|
128
|
+
issuer = Kiosk.current_issuer
|
|
129
|
+
return if Array(aud) == [issuer]
|
|
130
|
+
|
|
131
|
+
log_audience_mismatch(aud, issuer)
|
|
132
|
+
refuse!('client assertion audience mismatch', AUDIENCE_HINT)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Operator log only, because the answer must not name an origin: the
|
|
136
|
+
# caller signed a wrong value, or this deployment does not serve the origin it reached.
|
|
137
|
+
def log_audience_mismatch(aud, issuer)
|
|
138
|
+
message = "[kiosk] client assertion audience mismatch: the caller signed aud=#{aud.inspect}, " \
|
|
139
|
+
"the issuer for this request is #{issuer.inspect}. If the signed value is an origin " \
|
|
140
|
+
'your assistants actually reach, list it in `c.issuer` or `c.additional_origins`.'
|
|
141
|
+
logger = ::Rails.logger
|
|
142
|
+
logger ? logger.warn(message) : warn(message)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def refuse!(message, hint)
|
|
146
|
+
raise Refused.new(message, hint: hint)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private_class_method :check_type!, :client_of, :registered_key, :decode, :check_claims!, :check_audience!,
|
|
150
|
+
:log_audience_mismatch, :refuse!
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
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) { insert_agent(conn, config, [create_assistant_account(config, pem), pem], role) }
|
|
73
|
+
Client.with_key(key)
|
|
74
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
75
|
+
Client.with_key(key)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# No role is the empty array: `allowed_roles` is NOT NULL.
|
|
79
|
+
def insert_agent(conn, config, binds, role)
|
|
80
|
+
allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
|
|
81
|
+
conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, Kiosk.current_issuer, *role_binds])
|
|
82
|
+
INSERT INTO #{config.schema}.agents (user_id, public_key, issuer, allowed_roles)
|
|
83
|
+
VALUES ($1, $2, $3, #{allowed_roles_sql})
|
|
84
|
+
SQL
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def invalid!(description)
|
|
88
|
+
raise OauthError.new(:invalid_client_metadata, description)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private_class_method :checked_key, :check_grant_types!, :registration_role, :register, :insert_agent, :invalid!
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -35,7 +35,7 @@ module Kiosk
|
|
|
35
35
|
attr_writer :skill_url
|
|
36
36
|
|
|
37
37
|
def skill_url
|
|
38
|
-
@skill_url ||= 'https://kiosk.tech/skill-v0.
|
|
38
|
+
@skill_url ||= 'https://kiosk.tech/skill-v0.6.1.md'
|
|
39
39
|
end
|
|
40
40
|
attr_accessor :skill_sha256
|
|
41
41
|
|
|
@@ -57,7 +57,7 @@ module Kiosk
|
|
|
57
57
|
|
|
58
58
|
# The proof-of-work settings: registration proofs and the reputation challenge.
|
|
59
59
|
module ProofOfWork
|
|
60
|
-
# Equihash proofs asked at `POST /
|
|
60
|
+
# Equihash proofs asked at `POST /oauth/register`, at the link-code claim and
|
|
61
61
|
# at the first token poll of each device code, at the gem's default params.
|
|
62
62
|
# Default 0 (open). Above 0 needs `pow_secret` and `kiosk-pow-equihash`.
|
|
63
63
|
attr_writer :registration_pow_count
|
|
@@ -122,20 +122,16 @@ module Kiosk
|
|
|
122
122
|
@kyc_audience || Kiosk.current_issuer
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
# The KYC provider's
|
|
125
|
+
# The KYC provider's Ed25519 public key: an OpenSSL::PKey or a PEM string.
|
|
126
126
|
def kyc_public_key
|
|
127
127
|
@kyc_public_key
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
def kyc_public_key=(value)
|
|
131
|
-
@kyc_public_key =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
else
|
|
136
|
-
raise ArgumentError,
|
|
137
|
-
"kyc_public_key must be an OpenSSL::PKey or PEM string, got #{value.class}"
|
|
138
|
-
end
|
|
131
|
+
@kyc_public_key = value.is_a?(String) ? OpenSSL::PKey.read(value) : value
|
|
132
|
+
return if @kyc_public_key.nil? || Jwk.ed25519?(@kyc_public_key)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError, "kyc_public_key must be an Ed25519 key or its PEM string, got #{@kyc_public_key.class}"
|
|
139
135
|
end
|
|
140
136
|
end
|
|
141
137
|
|
|
@@ -169,27 +165,50 @@ module Kiosk
|
|
|
169
165
|
include Kyc
|
|
170
166
|
include Database
|
|
171
167
|
|
|
172
|
-
# Required. A SigningKey or
|
|
168
|
+
# Required. A SigningKey or the PEM string of an Ed25519 key.
|
|
173
169
|
def signing_key
|
|
174
170
|
@signing_key || raise(Errors::ConfigurationError,
|
|
175
|
-
|
|
171
|
+
"c.signing_key is not set. #{SigningKey::HINT.capitalize}.")
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
def signing_key=(value)
|
|
179
|
-
@signing_key =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
175
|
+
@signing_key = signing_key_from(value, 'signing_key')
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Optional. The key that signed before {#signing_key}: the JWKS still
|
|
179
|
+
# publishes it, and a token or checkout it signed still verifies. A public
|
|
180
|
+
# PEM is enough. Keep it for at least one access-token lifetime, and while
|
|
181
|
+
# a checkout it signed can still be paid.
|
|
182
|
+
attr_reader :previous_signing_key
|
|
183
|
+
|
|
184
|
+
def previous_signing_key=(value)
|
|
185
|
+
@previous_signing_key = signing_key_from(value, 'previous_signing_key')
|
|
184
186
|
end
|
|
185
187
|
|
|
186
|
-
# The
|
|
188
|
+
# The keys the JWKS publishes and verification accepts, the current one first.
|
|
189
|
+
def signing_keys = [signing_key, previous_signing_key].compact
|
|
190
|
+
|
|
191
|
+
# The JOSE algorithms an AI assistant can sign with. Default `["EdDSA"]`:
|
|
192
|
+
# Ed25519 keys only. `%w[EdDSA ES256]` also accepts P-256 keys.
|
|
193
|
+
def assistant_algorithms
|
|
194
|
+
@assistant_algorithms || [Eddsa::NAME]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def assistant_algorithms=(value)
|
|
198
|
+
algorithms = Array(value).map(&:to_s)
|
|
199
|
+
valid = [[Eddsa::NAME], [Eddsa::NAME, 'ES256']].include?(algorithms)
|
|
200
|
+
raise Errors::ConfigurationError, "c.assistant_algorithms is %w[EdDSA] or %w[EdDSA ES256], got #{algorithms.inspect}" unless valid
|
|
201
|
+
|
|
202
|
+
@assistant_algorithms = algorithms.freeze
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The role every registered client is pinned to, and the default a
|
|
187
206
|
# binding falls back to. Required, and one of {#roles}, when {#roles} is set.
|
|
188
207
|
# Kiosk.configure { |c| c.roles = %i[customer]; c.registration_role = :customer }
|
|
189
208
|
attr_accessor :registration_role
|
|
190
209
|
|
|
191
|
-
# Optional `->(
|
|
192
|
-
#
|
|
210
|
+
# Optional `->(public_key_pem) { record_id }` creating the account behind a
|
|
211
|
+
# registered client. Unset: `user_model.constantize.create!`.
|
|
193
212
|
attr_accessor :assistant_creation
|
|
194
213
|
|
|
195
214
|
# Account-binding ceremony state. Default: the shared database store.
|
|
@@ -224,7 +243,7 @@ module Kiosk
|
|
|
224
243
|
true
|
|
225
244
|
end
|
|
226
245
|
|
|
227
|
-
# Called as `(agent:, from:, to:)` when an assistant's
|
|
246
|
+
# Called as `(agent:, from:, to:)` when an assistant's client moves from one
|
|
228
247
|
# account to another, inside the rebind transaction: the place to move
|
|
229
248
|
# the operator's own rows. `from` and `to` are `user_model` records.
|
|
230
249
|
attr_accessor :assistant_claimed
|
|
@@ -234,7 +253,7 @@ module Kiosk
|
|
|
234
253
|
attr_accessor :assistant_unlinked
|
|
235
254
|
|
|
236
255
|
# Optional `(agent_id:) → { amount_cents:, currency: } | nil`, checked
|
|
237
|
-
# before capture; 0 blocks payments, and so does a
|
|
256
|
+
# before capture; 0 blocks payments, and so does a checkout in another currency.
|
|
238
257
|
# {Kiosk::Server::ColumnSpendingCap} reads
|
|
239
258
|
# `agents.spending_cap_cents`, the column edited by the manage-assistants
|
|
240
259
|
# page, and `agents.spending_cap_currency`.
|
|
@@ -243,8 +262,8 @@ module Kiosk
|
|
|
243
262
|
# Days of settled spend summed against the cap. Default nil: all time.
|
|
244
263
|
attr_accessor :spending_cap_window_days
|
|
245
264
|
|
|
246
|
-
# `call(id, lines) → cents | String`: the payable row's price, or why the
|
|
247
|
-
attr_accessor :
|
|
265
|
+
# `call(id, lines) → cents | String`: the payable row's price, or why the checkout is refused.
|
|
266
|
+
attr_accessor :checkout_price_checker
|
|
248
267
|
|
|
249
268
|
# Optional `call(id)`, run once the settlement that paid row `id` is recorded.
|
|
250
269
|
attr_accessor :after_payment
|
|
@@ -282,7 +301,8 @@ module Kiosk
|
|
|
282
301
|
@audit_sink = value
|
|
283
302
|
end
|
|
284
303
|
|
|
285
|
-
# Spent
|
|
304
|
+
# Spent ids, so a proof-of-work proof or a client assertion is accepted once.
|
|
305
|
+
# Default: the shared database table.
|
|
286
306
|
attr_writer :pow_spent_store
|
|
287
307
|
|
|
288
308
|
def pow_spent_store
|
|
@@ -290,16 +310,6 @@ module Kiosk
|
|
|
290
310
|
LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
|
|
291
311
|
end
|
|
292
312
|
|
|
293
|
-
# Outstanding auth nonces. Default: the shared database table.
|
|
294
|
-
attr_writer :auth_challenge_store
|
|
295
|
-
|
|
296
|
-
def auth_challenge_store
|
|
297
|
-
@auth_challenge_store ||
|
|
298
|
-
LAZY_STORE_MUTEX.synchronize do
|
|
299
|
-
@auth_challenge_store ||= Kiosk::Server::AuthChallengeStores::ActiveRecord.new
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
|
|
303
313
|
# Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
|
|
304
314
|
attr_writer :access_token_ttl
|
|
305
315
|
|
|
@@ -307,14 +317,6 @@ module Kiosk
|
|
|
307
317
|
@access_token_ttl ||= JwtIssuer::DEFAULT_EXPIRES_IN
|
|
308
318
|
end
|
|
309
319
|
|
|
310
|
-
# Seconds between `GET /auth/challenge` and the signed POST. Default covers
|
|
311
|
-
# the auth toll's proof-of-work window plus 60s.
|
|
312
|
-
attr_writer :auth_challenge_ttl
|
|
313
|
-
|
|
314
|
-
def auth_challenge_ttl
|
|
315
|
-
@auth_challenge_ttl ||= (pow_ttl * [registration_pow_count.to_i, 1].max) + 60
|
|
316
|
-
end
|
|
317
|
-
|
|
318
320
|
# Revocation watermarks, checked on every access token. Default: the
|
|
319
321
|
# shared database table, so a revoke or an unlink holds in every process.
|
|
320
322
|
attr_writer :revocation_store
|
|
@@ -323,6 +325,16 @@ module Kiosk
|
|
|
323
325
|
@revocation_store ||
|
|
324
326
|
LAZY_STORE_MUTEX.synchronize { @revocation_store ||= Kiosk::Server::RevocationStores::ActiveRecord.new }
|
|
325
327
|
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def signing_key_from(value, name)
|
|
332
|
+
case value
|
|
333
|
+
when Kiosk::Server::SigningKey, nil then value
|
|
334
|
+
when String then Kiosk::Server::SigningKey.from_pem(value)
|
|
335
|
+
else raise ArgumentError, "#{name} must be a SigningKey or PEM string, got #{value.class}"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
326
338
|
end
|
|
327
339
|
end
|
|
328
340
|
end
|