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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. 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 /auth/register`, the link-code claim `POST /auth/claim`, and
6
+ # token: `POST /oauth/register`, the link-code claim `POST /auth/claim`, and
7
7
  # the first token poll of each device code. It runs before any lookup, so
8
- # the answer never tells a caller whether a key or a code exists. It bounds
9
- # the cost per request, not the rate: an edge rate limit is required
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 pays for this request only.
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 = "register a fresh assistant account at this origin's register_url instead"
10
+ HINT = "use this origin with a client of your own: register it at this origin's registration_url"
11
11
 
12
12
  private
13
13
 
@@ -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}".agents
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?