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
@@ -8,7 +8,7 @@ module Kiosk
8
8
  # cannot each build their own store (a double-spent proof of work).
9
9
  LAZY_STORE_MUTEX = Mutex.new
10
10
 
11
- # The discovery settings: where the wire is mounted and what it advertises.
11
+ # The discovery settings: where the protocol is mounted and what it advertises.
12
12
  module Discovery
13
13
  # Default `/kiosk`. Discovery advertises `endpoint = origin + mount_path`.
14
14
  attr_writer :mount_path
@@ -30,13 +30,18 @@ module Kiosk
30
30
  @owner ||= {}
31
31
  end
32
32
 
33
- # The skill this origin was built against. Discovery emits the `skill`
34
- # block only when `skill_sha256` is set.
33
+ # The skill cut of this kiosk-server release. Each release gives the cut
34
+ # of its own version. Do not set it.
35
35
  attr_writer :skill_url
36
36
 
37
37
  def skill_url
38
- @skill_url ||= 'https://kiosk.tech/skill-v0.5.20.md'
38
+ @skill_url ||= 'https://kiosk.tech/skill-v0.6.28.md'
39
39
  end
40
+
41
+ # The SHA-256 of the file at `skill_url`, as hex. Default nil: discovery
42
+ # then advertises no `skill` block. Make it with
43
+ # `curl -fsS <skill_url> | shasum -a 256`. Kiosk::TestHelpers::SkillPin
44
+ # fails when an update of kiosk-server changes `skill_url`.
40
45
  attr_accessor :skill_sha256
41
46
 
42
47
  private
@@ -57,7 +62,7 @@ module Kiosk
57
62
 
58
63
  # The proof-of-work settings: registration proofs and the reputation challenge.
59
64
  module ProofOfWork
60
- # Equihash proofs asked at `POST /auth/register`, at the link-code claim and
65
+ # Equihash proofs asked at `POST /oauth/register`, at the link-code claim and
61
66
  # at the first token poll of each device code, at the gem's default params.
62
67
  # Default 0 (open). Above 0 needs `pow_secret` and `kiosk-pow-equihash`.
63
68
  attr_writer :registration_pow_count
@@ -107,45 +112,15 @@ module Kiosk
107
112
  def kyc_claims
108
113
  @kyc_claims || []
109
114
  end
110
-
111
- # Must match the `iss` of submitted KYC attestations.
112
- attr_writer :kyc_issuer
113
-
114
- def kyc_issuer
115
- @kyc_issuer
116
- end
117
-
118
- # The `aud` a KYC attestation must carry. Default: the origin being served.
119
- attr_writer :kyc_audience
120
-
121
- def kyc_audience
122
- @kyc_audience || Kiosk.current_issuer
123
- end
124
-
125
- # The KYC provider's RSA key: an OpenSSL::PKey or a PEM string.
126
- def kyc_public_key
127
- @kyc_public_key
128
- end
129
-
130
- def kyc_public_key=(value)
131
- @kyc_public_key = case value
132
- when OpenSSL::PKey::PKey then value
133
- when String then OpenSSL::PKey::RSA.new(value)
134
- when nil then nil
135
- else
136
- raise ArgumentError,
137
- "kyc_public_key must be an OpenSSL::PKey or PEM string, got #{value.class}"
138
- end
139
- end
140
115
  end
141
116
 
142
117
  # The database settings: where Kiosk's tables live and how a request reaches them.
143
118
  module Database
144
- # Postgres schema for Kiosk's own tables and functions. Default 'kiosk'.
119
+ # The Postgres schema of Kiosk's `kiosk_*` tables. Default 'public'.
145
120
  attr_writer :schema
146
121
 
147
122
  def schema
148
- @schema ||= 'kiosk'
123
+ @schema ||= 'public'
149
124
  end
150
125
 
151
126
  # Prefix of the session GUCs a request sets, e.g. `app.user_id`. Default 'app'.
@@ -169,27 +144,50 @@ module Kiosk
169
144
  include Kyc
170
145
  include Database
171
146
 
172
- # Required. A SigningKey or a PEM string.
147
+ # Required. A SigningKey or the PEM string of an Ed25519 key.
173
148
  def signing_key
174
149
  @signing_key || raise(Errors::ConfigurationError,
175
- 'c.signing_key is not set. Generate one with `openssl genrsa 2048`.')
150
+ "c.signing_key is not set. #{SigningKey::HINT.capitalize}.")
176
151
  end
177
152
 
178
153
  def signing_key=(value)
179
- @signing_key = case value
180
- when Kiosk::Server::SigningKey, nil then value
181
- when String then Kiosk::Server::SigningKey.from_pem(value)
182
- else raise ArgumentError, "signing_key must be a SigningKey or PEM string, got #{value.class}"
183
- end
154
+ @signing_key = signing_key_from(value, 'signing_key')
155
+ end
156
+
157
+ # Optional. The key that signed before {#signing_key}: the JWKS still
158
+ # publishes it, and a token or checkout it signed still verifies. A public
159
+ # PEM is enough. Keep it for at least one access-token lifetime, and while
160
+ # a checkout it signed can still be paid.
161
+ attr_reader :previous_signing_key
162
+
163
+ def previous_signing_key=(value)
164
+ @previous_signing_key = signing_key_from(value, 'previous_signing_key')
165
+ end
166
+
167
+ # The keys the JWKS publishes and verification accepts, the current one first.
168
+ def signing_keys = [signing_key, previous_signing_key].compact
169
+
170
+ # The JOSE algorithms an AI assistant can sign with. Default `["EdDSA"]`:
171
+ # Ed25519 keys only. `%w[EdDSA ES256]` also accepts P-256 keys.
172
+ def assistant_algorithms
173
+ @assistant_algorithms || [Eddsa::NAME]
184
174
  end
185
175
 
186
- # The role every self-registered agent is pinned to, and the default a
176
+ def assistant_algorithms=(value)
177
+ algorithms = Array(value).map(&:to_s)
178
+ valid = [[Eddsa::NAME], [Eddsa::NAME, 'ES256']].include?(algorithms)
179
+ raise Errors::ConfigurationError, "c.assistant_algorithms is %w[EdDSA] or %w[EdDSA ES256], got #{algorithms.inspect}" unless valid
180
+
181
+ @assistant_algorithms = algorithms.freeze
182
+ end
183
+
184
+ # The role every registered client is pinned to, and the default a
187
185
  # binding falls back to. Required, and one of {#roles}, when {#roles} is set.
188
186
  # Kiosk.configure { |c| c.roles = %i[customer]; c.registration_role = :customer }
189
187
  attr_accessor :registration_role
190
188
 
191
- # Optional `->(public_key) { record_id }` creating the account behind a
192
- # self-registered agent. Unset: `user_model.constantize.create!`.
189
+ # Optional `->(public_key_pem) { record_id }` creating the account behind a
190
+ # registered client. Unset: `user_model.constantize.create!`.
193
191
  attr_accessor :assistant_creation
194
192
 
195
193
  # Account-binding ceremony state. Default: the shared database store.
@@ -224,7 +222,7 @@ module Kiosk
224
222
  true
225
223
  end
226
224
 
227
- # Called as `(agent:, from:, to:)` when an assistant's key moves from one
225
+ # Called as `(agent:, from:, to:)` when an assistant's client moves from one
228
226
  # account to another, inside the rebind transaction: the place to move
229
227
  # the operator's own rows. `from` and `to` are `user_model` records.
230
228
  attr_accessor :assistant_claimed
@@ -233,18 +231,16 @@ module Kiosk
233
231
  # `account`, a `user_model` record, and its tokens are revoked.
234
232
  attr_accessor :assistant_unlinked
235
233
 
236
- # Optional `(agent_id:) → { amount_cents:, currency: } | nil`, checked
237
- # before capture; 0 blocks payments, and so does a cart in another currency.
234
+ # Optional `(agent_id:) → { amount_cents:, currency: } | nil`: the most
235
+ # one `pay` may charge, checked before capture. 0 blocks payments, and so
236
+ # does a checkout in another currency.
238
237
  # {Kiosk::Server::ColumnSpendingCap} reads
239
238
  # `agents.spending_cap_cents`, the column edited by the manage-assistants
240
239
  # page, and `agents.spending_cap_currency`.
241
240
  attr_accessor :spending_cap
242
241
 
243
- # Days of settled spend summed against the cap. Default nil: all time.
244
- attr_accessor :spending_cap_window_days
245
-
246
- # `call(id, lines) → cents | String`: the payable row's price, or why the cart is refused.
247
- attr_accessor :cart_price_checker
242
+ # `call(id, lines) → cents | String`: the payable row's price, or why the checkout is refused.
243
+ attr_accessor :checkout_price_checker
248
244
 
249
245
  # Optional `call(id)`, run once the settlement that paid row `id` is recorded.
250
246
  attr_accessor :after_payment
@@ -282,7 +278,8 @@ module Kiosk
282
278
  @audit_sink = value
283
279
  end
284
280
 
285
- # Spent challenge ids, so a proof is accepted once. Default: the shared database table.
281
+ # Spent ids, so a proof-of-work proof or a client assertion is accepted once.
282
+ # Default: the shared database table.
286
283
  attr_writer :pow_spent_store
287
284
 
288
285
  def pow_spent_store
@@ -290,31 +287,13 @@ module Kiosk
290
287
  LAZY_STORE_MUTEX.synchronize { @pow_spent_store ||= Kiosk::Server::PowSpentStores::ActiveRecord.new }
291
288
  end
292
289
 
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
- # Seconds an access token lives. One paid proof-of-work toll lasts as long. Default 3600.
290
+ # Seconds an access token lives. Default 3600.
304
291
  attr_writer :access_token_ttl
305
292
 
306
293
  def access_token_ttl
307
294
  @access_token_ttl ||= JwtIssuer::DEFAULT_EXPIRES_IN
308
295
  end
309
296
 
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
297
  # Revocation watermarks, checked on every access token. Default: the
319
298
  # shared database table, so a revoke or an unlink holds in every process.
320
299
  attr_writer :revocation_store
@@ -323,6 +302,16 @@ module Kiosk
323
302
  @revocation_store ||
324
303
  LAZY_STORE_MUTEX.synchronize { @revocation_store ||= Kiosk::Server::RevocationStores::ActiveRecord.new }
325
304
  end
305
+
306
+ private
307
+
308
+ def signing_key_from(value, name)
309
+ case value
310
+ when Kiosk::Server::SigningKey, nil then value
311
+ when String then Kiosk::Server::SigningKey.from_pem(value)
312
+ else raise ArgumentError, "#{name} must be a SigningKey or PEM string, got #{value.class}"
313
+ end
314
+ end
326
315
  end
327
316
  end
328
317
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The wire request being dispatched, visible to its handler for the duration
5
+ # The protocol request being dispatched, visible to its handler for the duration
6
6
  # of one block. Fiber-local: a handler that hands work to another thread must
7
7
  # pass what it needs.
8
8
  module CurrentRequest
@@ -5,7 +5,7 @@ require 'digest'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # One account-binding request on the RFC 8628 device-grant wire: a `:claim`
8
+ # One account-binding request on the RFC 8628 device-grant endpoints: a `:claim`
9
9
  # the agent opens and a human approves, or a `:link` a signed-in human
10
10
  # creates already approved. Only SHA-256 digests of its two codes are kept.
11
11
  #
@@ -147,7 +147,7 @@ module Kiosk
147
147
  private
148
148
 
149
149
  def connection = ::ActiveRecord::Base.lease_connection
150
- def table = %("#{Kiosk.configuration.schema}".device_authorizations)
150
+ def table = %("#{Kiosk.configuration.schema}".kiosk_device_authorizations)
151
151
 
152
152
  def insert_sql
153
153
  placeholders = (1..COLUMNS.size).map { |i| "$#{i}" }.join(', ')
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The claim ceremony on the RFC 8628 device-grant wire: binds an agent key
6
- # to an existing human account once the human approves.
5
+ # The claim ceremony on the RFC 8628 device-grant endpoints: binds a registered
6
+ # client to an existing human account once the human approves.
7
7
  #
8
- # .start — POST /oauth/device_authorization
9
- # .exchange — POST /oauth/token (grant_type=device_code)
8
+ # .start — POST /oauth/device_authorization
9
+ # .pay_toll! — POST /oauth/token (grant_type=device_code), before the client authenticates
10
+ # .exchange — POST /oauth/token (grant_type=device_code), after it authenticates
10
11
  module DeviceCodeGrant
11
- GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
12
+ GRANT_TYPE = Client::DEVICE_CODE
12
13
 
13
14
  # Seconds; a faster poll gets `slow_down` (RFC 8628 §3.5).
14
15
  DEFAULT_POLL_INTERVAL = 5
@@ -18,9 +19,6 @@ module Kiosk
18
19
  @poll_registry = {}
19
20
  @poll_mutex = Mutex.new
20
21
 
21
- PROOF_REQUIRED = 'signed proof-of-possession required ' \
22
- '(GET /auth/challenge?public_key=… then sign {aud, nonce, jti})'
23
-
24
22
  STATUS_FAILURES = {
25
23
  pending: [:authorization_pending, 'the account holder has not yet approved'],
26
24
  denied: [:access_denied, 'the account holder denied the request'],
@@ -30,14 +28,13 @@ module Kiosk
30
28
 
31
29
  module_function
32
30
 
33
- # No role parameter on purpose: the row takes the approving human's role.
34
- def start(client_id:,
35
- public_key_pem:,
31
+ # No role parameter on purpose: the client takes the approving human's role.
32
+ def start(client:,
36
33
  store: Kiosk.configuration.device_authorization_store,
37
34
  expires_in: DeviceAuthorization::DEFAULT_EXPIRES_IN,
38
35
  now: Time.current)
39
36
  plain_device_code, plain_user_code, da = DeviceAuthorization.generate(
40
- client_id: client_id, kind: :claim, public_key_pem: public_key_pem.to_s.strip,
37
+ client_id: client.id, kind: :claim, public_key_pem: client.public_key_pem,
41
38
  expires_in: expires_in, now: now
42
39
  )
43
40
  store.create(da)
@@ -45,26 +42,30 @@ module Kiosk
45
42
  expires_in: expires_in, interval: DEFAULT_POLL_INTERVAL, da: da }
46
43
  end
47
44
 
48
- # Returns `{ok: true, access_token:, …}` or `{ok: false, error:, description:}`
49
- # with an RFC 8628 §3.5 error code. The auth toll raises its own errors.
50
- def exchange(device_code:, # rubocop:disable Metrics/ParameterLists -- the token controller and the specs pass these by name
51
- signed: nil,
52
- pow: nil,
45
+ # The first poll of each device code value is tolled; later polls
46
+ # of that value are free until any code could have expired. The store is
47
+ # not read first, so an unknown code costs the same work as a live one.
48
+ def pay_toll!(device_code, pow, now: Time.current)
49
+ config = Kiosk.configuration
50
+ paid = "device_code:#{DeviceAuthorization.hash_device_code(device_code)}"
51
+ return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
52
+
53
+ AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
54
+ config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
55
+ end
56
+
57
+ # @return [Client] the client under the approving human's account
58
+ # @raise [OauthError] with an RFC 8628 §3.5 error code
59
+ def exchange(device_code:, client:,
53
60
  store: Kiosk.configuration.device_authorization_store,
54
61
  interval: DEFAULT_POLL_INTERVAL,
55
62
  now: Time.current)
56
- return failure(:invalid_request, 'device_code parameter required') if device_code.to_s.empty?
57
-
58
63
  hash = DeviceAuthorization.hash_device_code(device_code)
59
- pay_toll_once!(device_code, hash, pow, now)
60
- da = store.lookup_by_device_code_hash(hash)
61
- return failure(:invalid_grant, 'unknown device_code') if da.nil?
62
- return failure(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
63
-
64
- da = expire_if_due(da, store, now)
65
- return bind_and_mint(authorization: da, signed: signed, store: store, now: now) if da.approved?
64
+ da = authorization_of(client, store.lookup_by_device_code_hash(hash))
65
+ fail!(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
66
66
 
67
- failure(*STATUS_FAILURES.fetch(da.status))
67
+ consume!(expire_if_due(da, store, now), store, now)
68
+ AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
68
69
  end
69
70
 
70
71
  def reset_poll_registry!
@@ -74,16 +75,11 @@ module Kiosk
74
75
  class << self
75
76
  private
76
77
 
77
- # The first poll of each device code value pays the auth toll; later polls
78
- # of that value are free until any code could have expired. The store is
79
- # not read first, so an unknown code costs the same work as a live one.
80
- def pay_toll_once!(device_code, hash, pow, now)
81
- config = Kiosk.configuration
82
- paid = "device_code:#{hash}"
83
- return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
78
+ # A claim row that this client opened; a row of another client is unknown to it.
79
+ def authorization_of(client, authorization)
80
+ return authorization if authorization&.claim? && authorization.client_id == client.id
84
81
 
85
- AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
86
- config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
82
+ fail!(:invalid_grant, 'unknown device_code')
87
83
  end
88
84
 
89
85
  def expire_if_due(authorization, store, now)
@@ -93,41 +89,14 @@ module Kiosk
93
89
  store.update(authorization.expire)
94
90
  end
95
91
 
96
- # Possession of the row's key is proven before binding; a failed proof
97
- # is `invalid_client` and leaves the row approved for a retry.
98
- def bind_and_mint(authorization:, signed:, store:, now:)
99
- refusal = possession_refusal(authorization.public_key_pem, signed)
100
- return refusal if refusal
101
- # Atomic, so two concurrent polls mint one token.
102
- return failure(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
103
-
104
- token_response(authorization)
105
- end
106
-
107
- def possession_refusal(public_key_pem, signed)
108
- return failure(:invalid_client, PROOF_REQUIRED) if signed.nil? || signed.to_s.empty?
109
- return failure(:invalid_client, 'no public key bound to this authorization') if public_key_pem.blank?
110
-
111
- payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
112
- AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
113
- nil
114
- rescue Errors::Base => e
115
- failure(:invalid_client, e.message)
116
- end
117
-
118
- def token_response(authorization)
119
- role = authorization.requested_role
120
- result = AccountBinding.bind!(public_key_pem: authorization.public_key_pem,
121
- user_id: authorization.user_id, requested_role: role)
122
- response = { ok: true, access_token: result[:access_token], token_type: 'Bearer',
123
- expires_in: Kiosk.configuration.access_token_ttl }
124
- # RFC 6749 §5.1: the granted scope is the approving human's role.
125
- response[:scope] = role if role
126
- response
92
+ # Atomic, so two concurrent polls bind once.
93
+ def consume!(authorization, store, now)
94
+ fail!(*STATUS_FAILURES.fetch(authorization.status)) unless authorization.approved?
95
+ fail!(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
127
96
  end
128
97
 
129
- def failure(error, description)
130
- { ok: false, error: error.to_s, description: description }
98
+ def fail!(error, description)
99
+ raise OauthError.new(error, description)
131
100
  end
132
101
 
133
102
  # Per process; a multi-process origin rate-limits at its edge.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require 'jwt'
6
+ require 'uri'
7
+
8
+ module Kiosk
9
+ module Server
10
+ # RFC 9449 (spec Section 5.6): a token request, and a request that carries
11
+ # an access token, come with a proof that the registered key signed for
12
+ # this request. The proof's iat window is the replay bound; no nonce is
13
+ # issued and no jti is kept.
14
+ module Dpop
15
+ SCHEME = 'DPoP'
16
+ TYPE = 'dpop+jwt'
17
+ WINDOW = 60
18
+ CLAIMS = %w[jti htm htu iat].freeze
19
+
20
+ # A proof that does not hold.
21
+ class Invalid < StandardError; end
22
+
23
+ module_function
24
+
25
+ # The `WWW-Authenticate` value of a 401 on a request that needs a token.
26
+ # `resource_metadata` names the protected-resource metadata (RFC 9728
27
+ # Section 5.1).
28
+ def challenge
29
+ %(#{SCHEME} algs="#{AssistantKey.algorithms.join(' ')}", ) +
30
+ %(resource_metadata="#{Kiosk.current_issuer}/.well-known/oauth-protected-resource")
31
+ end
32
+
33
+ def hint
34
+ 'send `Authorization: DPoP <access_token>` and one `DPoP` header: a JWS with typ dpop+jwt, ' \
35
+ "alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered key, signed by " \
36
+ 'that key, whose payload carries jti, htm (the method), htu (the URL with no query), iat (now) ' \
37
+ 'and ath (base64url SHA-256 of the access token)'
38
+ end
39
+
40
+ def token_request_hint
41
+ 'send one `DPoP` header: a JWS with typ dpop+jwt, ' \
42
+ "alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered " \
43
+ 'key, signed by that key, whose payload carries jti, htm POST, htu (the token URL) ' \
44
+ 'and iat (now)'
45
+ end
46
+
47
+ # The access token of an `Authorization: DPoP <token>` header, else nil.
48
+ def token(authorization)
49
+ authorization.to_s[/\A#{SCHEME}\s+(\S+)\z/io, 1]
50
+ end
51
+
52
+ # Raises Errors::AccessTokenRefused unless `proof` is the key `jkt`'s proof
53
+ # for this method, URL and token, signed within {WINDOW} seconds of now.
54
+ def verify!(proof, token:, jkt:, method:, url:)
55
+ raise Invalid, 'the access token is bound to no key' if jkt.to_s.empty?
56
+
57
+ claims = check!(proof, jkt:, method:, url:, key: 'the key the token is bound to')
58
+ raise Invalid, 'the DPoP proof ath is not the hash of this token' unless claims['ath'] == hash_of(token)
59
+ rescue Invalid => e
60
+ raise Errors::AccessTokenRefused, e.message
61
+ end
62
+
63
+ # Raises OauthError `invalid_dpop_proof` unless `proof` is the proof of
64
+ # the registered key `jkt` for this token request. It carries no `ath`:
65
+ # there is no token yet.
66
+ def verify_token_request!(proof, jkt:, url:)
67
+ check!(proof, jkt:, method: 'POST', url:, key: 'your registered key')
68
+ rescue Invalid => e
69
+ raise OauthError.new(:invalid_dpop_proof, "#{e.message}: #{token_request_hint}")
70
+ end
71
+
72
+ # The base64url SHA-256 of the token: the `ath` a proof carries.
73
+ def hash_of(token) = Base64.urlsafe_encode64(Digest::SHA256.digest(token.to_s), padding: false)
74
+
75
+ # `key` names the key `jkt` is the thumbprint of.
76
+ def check!(proof, jkt:, method:, url:, key:)
77
+ raise Invalid, 'one DPoP header is required' if proof.to_s.empty? || proof.include?(',')
78
+
79
+ signer, claims = decode(proof)
80
+ raise Invalid, "the DPoP proof key is not #{key}" unless AssistantKey.thumbprint(signer) == jkt
81
+
82
+ check_request!(claims, method, url)
83
+ claims
84
+ end
85
+
86
+ def check_request!(claims, method, url)
87
+ raise Invalid, 'the DPoP proof htm is not this request method' unless claims['htm'] == method
88
+ raise Invalid, 'the DPoP proof htu is not this request URL' unless same_url?(claims['htu'], url)
89
+ raise Invalid, 'the DPoP proof iat is not within 60 seconds of now' unless (claims['iat'].to_i - Time.now.to_i).abs <= WINDOW
90
+ end
91
+
92
+ # The key is the proof's own `jwk`, and its type names the one algorithm.
93
+ def decode(proof)
94
+ key = header_key(::JWT.decode(proof, nil, false).last)
95
+ algorithm = AssistantKey.algorithm(key)
96
+ claims, = ::JWT.decode(proof, key, true, algorithms: [algorithm], required_claims: CLAIMS)
97
+ [key, claims]
98
+ rescue ::JWT::IncorrectAlgorithm
99
+ raise Invalid, "the DPoP proof alg is not #{algorithm}, the algorithm of its jwk"
100
+ rescue ::JWT::DecodeError
101
+ raise Invalid, 'the DPoP proof is not a JWS that its jwk signed, with the claims of RFC 9449'
102
+ end
103
+
104
+ def header_key(header)
105
+ raise Invalid, 'the DPoP proof typ is not dpop+jwt' unless header['typ'] == TYPE
106
+
107
+ jwk = header['jwk']
108
+ raise Invalid, 'the DPoP proof carries no public jwk' unless jwk.is_a?(Hash) && !jwk.keys.intersect?(AssistantKey::PRIVATE_MEMBERS)
109
+
110
+ AssistantKey.from_jwk(jwk)
111
+ rescue Errors::BadRequest => e
112
+ raise Invalid, "the DPoP proof jwk is refused: #{e.message}"
113
+ end
114
+
115
+ # Scheme and host without case, a default port dropped (RFC 9449 Section 4.3).
116
+ def same_url?(htu, url)
117
+ Kiosk::Configuration.normalize_origin(htu) == Kiosk::Configuration.normalize_origin(url) &&
118
+ URI.parse(htu.to_s).path == URI.parse(url).path
119
+ rescue URI::InvalidURIError
120
+ false
121
+ end
122
+
123
+ private_class_method :check!, :check_request!, :decode, :header_key, :same_url?
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'openssl'
5
+ require_relative 'jwk'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # The JOSE algorithm EdDSA (RFC 8037) over the Ed25519 keys of OpenSSL.
10
+ # Loading this file registers `EdDSA` with ruby-jwt, so `JWT.encode` and
11
+ # `JWT.decode` accept the name.
12
+ class Eddsa
13
+ include ::JWT::JWA::SigningAlgorithm
14
+
15
+ NAME = 'EdDSA'
16
+
17
+ def initialize
18
+ @alg = NAME
19
+ end
20
+
21
+ def sign(data:, signing_key:)
22
+ raise_sign_error!("an EdDSA key is an Ed25519 key, got #{signing_key.class}") unless Jwk.ed25519?(signing_key)
23
+
24
+ signing_key.sign(nil, data)
25
+ end
26
+
27
+ def verify(data:, signature:, verification_key:)
28
+ raise_verify_error!("an EdDSA key is an Ed25519 key, got #{verification_key.class}") unless Jwk.ed25519?(verification_key)
29
+
30
+ verification_key.verify(nil, signature, data)
31
+ rescue OpenSSL::PKey::PKeyError
32
+ false
33
+ end
34
+
35
+ register_algorithm(new)
36
+ end
37
+ end
38
+ end