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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +63 -41
  4. data/app/controllers/kiosk/server/auth_controller.rb +18 -62
  5. data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
  6. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  7. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  8. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
  9. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  10. data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
  11. data/app/models/kiosk/checkout_mandate.rb +22 -0
  12. data/app/models/kiosk/settlement.rb +1 -1
  13. data/lib/generators/kiosk/install/install_generator.rb +0 -5
  14. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
  15. data/lib/kiosk/payable.rb +19 -25
  16. data/lib/kiosk/server/account_binding.rb +19 -58
  17. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  18. data/lib/kiosk/server/agent_rotation.rb +62 -0
  19. data/lib/kiosk/server/assistant_key.rb +86 -0
  20. data/lib/kiosk/server/auth_toll.rb +1 -1
  21. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  22. data/lib/kiosk/server/checkout.rb +57 -0
  23. data/lib/kiosk/server/client.rb +61 -0
  24. data/lib/kiosk/server/client_assertion.rb +153 -0
  25. data/lib/kiosk/server/client_registration.rb +94 -0
  26. data/lib/kiosk/server/configuration_extension.rb +56 -44
  27. data/lib/kiosk/server/device_code_grant.rb +38 -69
  28. data/lib/kiosk/server/dpop.rb +121 -0
  29. data/lib/kiosk/server/eddsa.rb +38 -0
  30. data/lib/kiosk/server/engine.rb +14 -11
  31. data/lib/kiosk/server/errors.rb +7 -0
  32. data/lib/kiosk/server/events_connection.rb +15 -5
  33. data/lib/kiosk/server/executor/repeat.rb +13 -12
  34. data/lib/kiosk/server/executor/spending_cap.rb +25 -26
  35. data/lib/kiosk/server/executor.rb +82 -101
  36. data/lib/kiosk/server/identity_resolution.rb +12 -3
  37. data/lib/kiosk/server/jwk.rb +83 -0
  38. data/lib/kiosk/server/jwks.rb +6 -0
  39. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  40. data/lib/kiosk/server/kyc_verifier.rb +2 -2
  41. data/lib/kiosk/server/link_code.rb +9 -18
  42. data/lib/kiosk/server/linked_assistants.rb +1 -1
  43. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  44. data/lib/kiosk/server/oauth_answers.rb +30 -0
  45. data/lib/kiosk/server/oauth_error.rb +30 -0
  46. data/lib/kiosk/server/open_api.rb +22 -20
  47. data/lib/kiosk/server/payment_claim.rb +44 -75
  48. data/lib/kiosk/server/receipts.rb +29 -0
  49. data/lib/kiosk/server/request_validation.rb +1 -2
  50. data/lib/kiosk/server/revocation_stores.rb +8 -0
  51. data/lib/kiosk/server/schema_definitions.rb +33 -64
  52. data/lib/kiosk/server/schemas/auth.schema.json +368 -57
  53. data/lib/kiosk/server/schemas/binding.schema.json +23 -58
  54. data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
  55. data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
  56. data/lib/kiosk/server/sd_jwt.rb +104 -0
  57. data/lib/kiosk/server/signing_key.rb +18 -52
  58. data/lib/kiosk/server/verb_wire.rb +1 -1
  59. data/lib/kiosk/server/version.rb +1 -1
  60. data/lib/kiosk/server/well_known.rb +268 -204
  61. data/lib/kiosk/server/wire_rendering.rb +1 -1
  62. data/lib/kiosk/server.rb +2 -0
  63. data/lib/kiosk/skill_link.rb +6 -2
  64. data/listen.py +38 -8
  65. metadata +26 -17
  66. data/app/models/kiosk/cart_mandate.rb +0 -21
  67. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  68. data/lib/kiosk/server/agent_login.rb +0 -49
  69. data/lib/kiosk/server/agent_registration.rb +0 -88
  70. data/lib/kiosk/server/auth_challenge.rb +0 -33
  71. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  72. data/lib/kiosk/server/pop_verifier.rb +0 -87
@@ -1,31 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module Kiosk
6
7
  module Server
7
- # The discovery documents — kiosk.json, agents.txt, agents.json,
8
- # agent-configuration, api-catalog and auth.md — rendered as pure functions
9
- # of one configuration and the request's base URL, so they cannot drift.
8
+ # The discovery documents — kiosk.json, agents.txt, agents.json, the OAuth
9
+ # metadata (RFC 8414, RFC 9728), the skills index, ard.json, api-catalog and auth.md — rendered
10
+ # as pure functions of one configuration and the request's base URL, so they
11
+ # cannot drift.
10
12
  module WellKnown
11
13
  DOCUMENT_VERSION = '1.0'
12
14
 
13
15
  AGENTS_VERSION = '1.0'
14
- AGENTS_STANDARD = 'https://agents-txt.com/standard'
16
+ AGENTS_STANDARD = 'https://agents-txt.com'
17
+ AGENTS_SCHEMA = 'https://agents-txt.com/schema/agents-json/v1.0.json'
15
18
 
16
- # Modules with one fixed endpoint; the catalog links every verb separately.
17
- MODULE_ENDPOINTS = { 'pay' => 'pay' }.freeze
19
+ # agents.txt v1.0 names OAuth 2.0 `oauth2`. Its discovery document is the RFC 8414 metadata.
20
+ AUTHORIZATION = { protocols: %w[oauth2], discovery: '/.well-known/oauth-authorization-server' }.freeze
18
21
 
19
- PAYMENTS = {
20
- ap2: { description: 'Mandate-trust layer with VC presentations' },
21
- required: true
22
- }.freeze
22
+ SKILLS_INDEX_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
23
23
 
24
- AUTHORIZATION = {
25
- protocols: %w[agent-auth auth-md],
26
- discovery: '/.well-known/agent-configuration',
27
- identity: 'required'
28
- }.freeze
24
+ # AI Catalog requires `specVersion`; ARD v0.91 ignores it.
25
+ AI_CATALOG_VERSION = '1.0'
26
+
27
+ # The `name` and `description` of the pinned skill's frontmatter.
28
+ # kiosk-test-support's skill_pin_spec holds them to the published cut.
29
+ SKILL_NAME = 'kiosk-agent-protocol'
30
+ SKILL_DESCRIPTION = 'A protocol that lets an AI assistant use the service of any Kiosk operator -- ' \
31
+ 'a shop, a booking desk, a job board, a classifieds board, or data, not only ' \
32
+ 'commerce. Register, read the catalogue to learn the operations, then act: ' \
33
+ 'browse, order, book, apply, pay -- and subscribe to the operator\'s event ' \
34
+ 'stream for anything that you would otherwise wait for.'
35
+
36
+ ARD_API = 'The Kiosk discovery document of this origin: the endpoint, the sign-in and the catalogue. ' \
37
+ 'Read it with the Kiosk skill.'
29
38
 
30
39
  # `/.well-known/kiosk.json`.
31
40
  def self.build(base_url:, config: Kiosk.configuration)
@@ -50,64 +59,100 @@ module Kiosk
50
59
  base = base_url.to_s.chomp('/')
51
60
 
52
61
  lines = ['# agents.txt — https://agents-txt.com', "# JSON: #{base}/agents.json"]
53
- lines.push('', 'Protocols: ap2', 'Payments: required') if Shared.pay_served?(config)
54
- lines.push('', 'Authorization: agent-auth auth-md', 'Identity: required')
55
- lines.push('', "Skills: #{config.skill_url}") if config.skill_url && !config.skill_url.to_s.empty?
62
+ lines.push('', "Authorization: #{AUTHORIZATION[:protocols].join(', ')}")
63
+ lines.push('', "Skills: #{config.skill_url}") if Shared.skill?(config)
56
64
 
57
65
  "#{lines.join("\n")}\n"
58
66
  end
59
67
 
60
- # agents.json v1.0; Kiosk's pointers ride the `x-kiosk` extension.
68
+ # agents.json v1.0.
61
69
  def self.agents_json(base_url:, config: Kiosk.configuration)
62
70
  Shared.validate_issuer!(config)
63
- doc = { version: AGENTS_VERSION, standard: AGENTS_STANDARD,
64
- site: { name: site_name(config), url: base_url.to_s.chomp('/') } }
65
- doc[:payments] = PAYMENTS if Shared.pay_served?(config)
66
- doc[:authorization] = AUTHORIZATION
67
- doc[:skills] = Shared.skills_list(config)
68
- doc[:'x-kiosk'] = Shared.x_kiosk(config)
71
+ doc = { '$schema': AGENTS_SCHEMA, version: AGENTS_VERSION, standard: AGENTS_STANDARD,
72
+ site: { name: site_name(config), url: base_url.to_s.chomp('/') },
73
+ authorization: AUTHORIZATION }
74
+ doc[:skills] = [{ url: config.skill_url, description: SKILL_DESCRIPTION }] if Shared.skill?(config)
69
75
  doc
70
76
  end
71
77
 
72
- # `/.well-known/agent-configuration`: the kiosk-pop auth endpoints.
73
- def self.agent_configuration(base_url:, config: Kiosk.configuration)
78
+ # `/.well-known/oauth-authorization-server` (RFC 8414): registration,
79
+ # tokens, and the device-code grant where the origin serves binding.
80
+ def self.oauth_authorization_server(base_url:, config: Kiosk.configuration)
74
81
  Shared.validate_issuer!(config)
75
82
  base = base_url.to_s.chomp('/')
76
83
  endpoint = base + config.mount_path
84
+ doc = { issuer: Kiosk.current_issuer, registration_endpoint: "#{endpoint}/oauth/register",
85
+ token_endpoint: "#{endpoint}/oauth/token" }
86
+ doc[:device_authorization_endpoint] = "#{endpoint}/oauth/device_authorization" if config.serve_account_binding
87
+ # No authorization endpoint, so no response type. An AI assistant
88
+ # requests no scope, so no scope value.
89
+ doc.merge(jwks_uri: "#{endpoint}/.well-known/jwks.json", response_types_supported: [], scopes_supported: [],
90
+ grant_types_supported: Client.grant_types(config), **Shared.client_authentication,
91
+ service_documentation: "#{base}/auth.md", dpop_signing_alg_values_supported: AssistantKey.algorithms)
92
+ end
93
+
94
+ # `/.well-known/oauth-protected-resource` (RFC 9728). RFC 9728 §3.3 binds a
95
+ # document at this path to the origin, so `resource` is the origin. An
96
+ # AI assistant requests no scope, so `scopes_supported` is empty.
97
+ def self.oauth_protected_resource(base_url:, config: Kiosk.configuration)
98
+ Shared.validate_issuer!(config)
99
+ base = base_url.to_s.chomp('/')
100
+ { resource: base, authorization_servers: [Kiosk.current_issuer], scopes_supported: [],
101
+ bearer_methods_supported: ['header'],
102
+ dpop_signing_alg_values_supported: AssistantKey.algorithms, dpop_bound_access_tokens_required: true,
103
+ resource_documentation: "#{base}/auth.md" }
104
+ end
105
+
106
+ # `/.well-known/agent-skills/index.json` (Agent Skills Discovery v0.2.0):
107
+ # the pinned skill and its digest. nil without a `skill_sha256`.
108
+ def self.agent_skills_index(config: Kiosk.configuration)
109
+ return unless Shared.skill?(config) && config.skill_sha256
77
110
 
78
- {
79
- issuer: Kiosk.current_issuer,
80
- endpoints: Shared.auth_urls(endpoint),
81
- jwks_uri: "#{endpoint}/.well-known/jwks.json",
82
- auth_modes: %w[kiosk-pop user-claimed link-code],
83
- auth_md: "#{base}/auth.md"
84
- }
111
+ { '$schema': SKILLS_INDEX_SCHEMA,
112
+ skills: [{ name: SKILL_NAME, type: 'skill-md', description: SKILL_DESCRIPTION,
113
+ url: config.skill_url, digest: "sha256:#{config.skill_sha256}" }] }
85
114
  end
86
115
 
87
- # `/.well-known/api-catalog` (RFC 9727): the two service descriptions, then
88
- # every verb with its method in the `kiosk-method` extension attribute.
89
- # Composed from configuration and the registries only, never a query.
116
+ # `/.well-known/ard.json` (Agentic Resource Discovery v0.91), also served
117
+ # at `/.well-known/ai-catalog.json` (AI Catalog 1.0): this origin's
118
+ # kiosk.json and the skill that reads it.
119
+ def self.ard(base_url:, config: Kiosk.configuration)
120
+ Shared.validate_issuer!(config)
121
+ base = base_url.to_s.chomp('/')
122
+ urn = "urn:air:#{URI.parse(base).host}:kiosk"
123
+ entries = [{ identifier: "#{urn}:api", displayName: "#{site_name(config)} Kiosk endpoint",
124
+ type: 'application/json', url: "#{base}/.well-known/kiosk.json", description: ARD_API }]
125
+ if Shared.skill?(config)
126
+ entries << { identifier: "#{urn}:skill", displayName: SKILL_NAME, type: 'application/agent-skills+md',
127
+ url: config.skill_url, description: SKILL_DESCRIPTION }
128
+ end
129
+ { specVersion: AI_CATALOG_VERSION, entries: entries }
130
+ end
131
+
132
+ # `/.well-known/api-catalog` (RFC 9727): every verb as an `item` of the
133
+ # catalog, its method in the `kiosk-method` extension attribute, and the
134
+ # two service descriptions on the endpoint. Composed from configuration
135
+ # and the registries only, never a query.
90
136
  def self.api_catalog(base_url:, config: Kiosk.configuration)
91
137
  Shared.validate_issuer!(config)
92
138
  base = base_url.to_s.chomp('/')
93
139
  endpoint = base + config.mount_path
94
140
  modules = Array(config.capabilities)
95
141
 
96
- items = Catalog.service_descs(endpoint, modules, config) + Catalog.verb_items(endpoint) +
97
- Catalog.module_items(endpoint, modules)
98
- items << { href: "#{base}/agents.json", rel: 'item' }
99
-
100
- { linkset: [{ anchor: "#{base}/.well-known/api-catalog", item: items }] }
142
+ linkset = [{ anchor: "#{base}/.well-known/api-catalog",
143
+ item: Catalog.verb_items(endpoint) + Catalog.module_items(endpoint, modules) }]
144
+ descs = Catalog.service_descs(endpoint, modules, config)
145
+ linkset << { anchor: endpoint, 'service-desc': descs } if descs.any?
146
+ { linkset: linkset }
101
147
  end
102
148
 
103
- # `/auth.md`: the auth methods in auth.md's vocabulary and section order.
149
+ # `/auth.md`: the auth methods in auth.md's section order.
104
150
  def self.auth_md(base_url:, config: Kiosk.configuration)
105
151
  Shared.validate_issuer!(config)
106
152
  base = base_url.to_s.chomp('/')
107
153
  endpoint = base + config.mount_path
108
154
 
109
- AuthMd.render(site: site_name(config), base: base, endpoint: endpoint,
110
- urls: Shared.auth_urls(endpoint), skill_line: Shared.skill_discover_line(config))
155
+ AuthMd.render(site: site_name(config), base: base, endpoint: endpoint, urls: Shared.auth_urls(endpoint), config: config)
111
156
  end
112
157
 
113
158
  # The owner's name, else the issuer's host. Also titles the OpenAPI document.
@@ -137,60 +182,44 @@ module Kiosk
137
182
  }
138
183
  end
139
184
 
140
- def auth_section(endpoint)
141
- {
142
- kind: 'kiosk-pop',
143
- **auth_urls(endpoint).transform_keys { |step| :"#{step}_url" },
144
- # Additive: a client that ignores binding still has register/login.
145
- device_authorization_url: "#{endpoint}/oauth/device_authorization",
146
- claim_url: "#{endpoint}/auth/claim"
147
- }
185
+ # RFC 8414: how a client authenticates at the token endpoint.
186
+ def client_authentication
187
+ { token_endpoint_auth_methods_supported: [Client::AUTH_METHOD],
188
+ token_endpoint_auth_signing_alg_values_supported: AssistantKey.algorithms }
148
189
  end
149
190
 
150
- # Pointers, never a copy of `kiosk.json`'s facts.
151
- def x_kiosk(config)
152
- {
153
- schema: "#{config.mount_path}/schema?v=#{SchemaDocument.digest(config: config)}",
154
- api_catalog: '/.well-known/api-catalog',
155
- mount_path: config.mount_path,
156
- api_version: Kiosk::Protocol::API_VERSION
157
- }
191
+ # Every URL, also the binding ones: where binding is not served they answer 501.
192
+ def auth_section(endpoint)
193
+ auth_urls(endpoint).transform_keys { |step| :"#{step}_url" }
158
194
  end
159
195
 
160
196
  def auth_urls(endpoint)
161
197
  {
162
- challenge: "#{endpoint}/auth/challenge",
163
- register: "#{endpoint}/auth/register",
164
- login: "#{endpoint}/auth/login",
165
- revoke: "#{endpoint}/auth/revoke"
198
+ registration: "#{endpoint}/oauth/register",
199
+ token: "#{endpoint}/oauth/token",
200
+ rotate: "#{endpoint}/auth/rotate",
201
+ device_authorization: "#{endpoint}/oauth/device_authorization",
202
+ claim: "#{endpoint}/auth/claim"
166
203
  }
167
204
  end
168
205
 
169
- def pay_served?(config)
170
- Array(config.capabilities).map(&:to_s).include?('pay')
171
- end
172
-
173
206
  def host_of(issuer)
174
- require 'uri'
175
207
  URI.parse(issuer.to_s).host || issuer.to_s
176
208
  rescue URI::InvalidURIError
177
209
  issuer.to_s
178
210
  end
179
211
 
180
212
  def skill_discover_line(config)
181
- url = config.skill_url
182
- return '' if url.nil? || url.to_s.empty?
213
+ return '' unless skill?(config)
183
214
 
184
215
  sha = config.skill_sha256
185
- line = "- Wire skill for AI assistants: `#{url}`"
216
+ line = "- Wire skill for AI assistants: #{config.skill_url}"
186
217
  line += " (sha256 `#{sha}`)" if sha && !sha.to_s.empty?
187
218
  line
188
219
  end
189
220
 
190
- def skills_list(config)
191
- return [] if config.skill_url.nil? || config.skill_url.to_s.empty?
192
-
193
- [{ url: config.skill_url, description: 'Kiosk wire skill' }]
221
+ def skill?(config)
222
+ !config.skill_url.to_s.empty?
194
223
  end
195
224
 
196
225
  def validate_issuer!(config)
@@ -199,11 +228,11 @@ module Kiosk
199
228
  raise ArgumentError,
200
229
  'Kiosk.configuration.issuer must be set before serving ' \
201
230
  '/.well-known/kiosk.json — the issuer ' \
202
- 'is the AP2 mandate `iss` anchor'
231
+ 'is the origin a token or a checkout names'
203
232
  end
204
233
  end
205
234
 
206
- # The api-catalog's linkset items, in document order.
235
+ # The api-catalog's links.
207
236
  module Catalog
208
237
  module_function
209
238
 
@@ -213,7 +242,8 @@ module Kiosk
213
242
  return [] unless modules.include?('schema')
214
243
 
215
244
  version = SchemaDocument.digest(config: config)
216
- %w[schema openapi.json].map { |path| { href: "#{endpoint}/#{path}?v=#{version}", rel: 'service-desc' } }
245
+ [{ href: "#{endpoint}/schema?v=#{version}", type: 'application/json' },
246
+ { href: "#{endpoint}/openapi.json?v=#{version}", type: OpenApi::CONTENT_TYPE }]
217
247
  end
218
248
 
219
249
  # Sorted, so the document is byte-stable across boots.
@@ -223,139 +253,169 @@ module Kiosk
223
253
  end
224
254
 
225
255
  def module_items(endpoint, modules)
226
- MODULE_ENDPOINTS.filter_map do |mod, path|
227
- { href: "#{endpoint}/#{path}", rel: 'item' } if modules.include?(mod)
228
- end
256
+ modules.include?('pay') ? [verb_item(endpoint, 'pay', 'POST')] : []
229
257
  end
230
258
 
231
259
  # RFC 9264 §4.2.4.3: an extension attribute is an array of strings.
232
260
  def verb_item(endpoint, name, method)
233
- { href: "#{endpoint}/#{name}", rel: 'item', 'kiosk-method': [method] }
261
+ { href: "#{endpoint}/#{name}", 'kiosk-method': [method] }
234
262
  end
235
263
  end
236
264
 
237
265
  # The `/auth.md` document.
238
266
  # rubocop:disable-next Metrics/ModuleLength -- one Markdown document; its lines are prose
239
267
  module AuthMd
240
- def self.render(site:, base:, endpoint:, urls:, skill_line:)
268
+ # This operator also accepts a P-256 key, or says nothing.
269
+ def self.es256_line(config)
270
+ return '' unless config.assistant_algorithms.include?('ES256')
271
+
272
+ ' This provider also accepts a P-256 key (ES256).'
273
+ end
274
+
275
+ def self.render(site:, base:, endpoint:, urls:, config:)
241
276
  <<~MARKDOWN
242
- # #{site} — agent authentication
277
+ # auth.md — #{site}
243
278
 
244
279
  How AI assistants authenticate against this provider's Kiosk
245
280
  endpoint (`#{endpoint}`). Wire contract: the Kiosk specification
246
- (https://kiosk.tech/specification.html).
281
+ (https://kiosk.tech/specification.html). This file follows the
282
+ auth.md section order. Registration and tokens use OAuth 2.0
283
+ standards: RFC 7591, RFC 7523, RFC 9449 and RFC 8628.
247
284
 
248
285
  ## Discover
249
286
 
250
- - This file: `#{base}/auth.md`
251
- - Agent auth configuration: `#{base}/.well-known/agent-configuration`
252
- - Kiosk discovery document: `#{base}/.well-known/kiosk.json`
253
- - Verb catalogue (PUBLIC, no token): `#{endpoint}/schema`
254
- - Token-verification keys (JWKS): `#{endpoint}/.well-known/jwks.json`
255
- #{skill_line}
287
+ - This file: #{base}/auth.md
288
+ - Kiosk discovery document: #{base}/.well-known/kiosk.json
289
+ - OAuth metadata of this authorization server (RFC 8414): #{base}/.well-known/oauth-authorization-server
290
+ - OAuth metadata of this protected resource (RFC 9728): #{base}/.well-known/oauth-protected-resource
291
+ - Verb catalogue (PUBLIC, no token): #{endpoint}/schema
292
+ - Token-verification keys (JWKS): #{endpoint}/.well-known/jwks.json
293
+ #{Shared.skill_discover_line(config)}
256
294
 
257
295
  ## Pick a method
258
296
 
259
- - **Anonymous + proof-of-possession (kiosk-pop)** — supported.
260
- Self-registration of a per-provider RSA keypair: no human
261
- account needed. In auth.md terms this is the anonymous class,
262
- upgraded with a key-possession proof on every register/login.
263
- - **User claimed** — supported. The claim ceremony below binds an
264
- agent key to an EXISTING account after its holder approves in
265
- their own browser session.
266
- - **Link code** — supported (Kiosk extension: auth.md defines no
267
- human-initiated direction). The account holder mints a
268
- single-use code on the provider's site and hands it to the
269
- assistant.
297
+ - **Open registration** — supported. You register your own
298
+ public key (RFC 7591). No human account is necessary. You
299
+ authenticate each token request with a client assertion that
300
+ your key signs (`private_key_jwt`, RFC 7523).
301
+ - **Claim ceremony** — supported. It binds your registered client
302
+ to an EXISTING account after its holder approves in their own
303
+ browser session (RFC 8628).
304
+ - **Link code** — supported. The account holder makes a
305
+ single-use code on the provider's site and gives it to you.
270
306
  - **Identity assertion (ID-JAG)** — not supported (planned).
271
307
 
272
308
  ## Register
273
309
 
274
- kiosk-pop self-registration (anonymous class):
310
+ 1. Make one Ed25519 key for this provider. Keep the private
311
+ key.#{es256_line(config)}
312
+ 2. `POST #{urls[:registration]}` with `Content-Type: application/json`:
313
+
314
+ {"jwks":{"keys":[{"kty":"OKP","crv":"Ed25519","x":"…"}]},
315
+ "token_endpoint_auth_method":"private_key_jwt"}
275
316
 
276
- 1. `GET #{urls[:challenge]}?public_key=<PEM>` → `{ challenge, exp }`
277
- 2. Sign a compact RS256 JWS over `{aud, nonce, jti}` with the
278
- private key (`aud` = the origin you dialed; `nonce` = the
279
- challenge).
280
- 3. `POST #{urls[:register]}` `{ public_key, signed }`
281
- → `201 { agent_id, user_id, access_token }`
317
+ → `201 { client_id, client_id_issued_at, jwks,
318
+ token_endpoint_auth_method, grant_types, dpop_bound_access_tokens }`
319
+ 3. Keep `client_id`. Registration gives no token.
282
320
 
283
- Registration may be priced with an Equihash proof-of-work. When it
284
- is, step 3 answers `402 pow_required` with
285
- `WWW-Authenticate: Kiosk-PoW realm="<issuer>"` and the body carries
286
- the `challenges` array; the toll binds to the public key you are
287
- registering. Solve EVERY challenge and resubmit the SAME body — the
288
- possession proof is NOT consumed by the 402, so reuse the same
289
- `signed` — carrying the proof(s) in a `Kiosk-PoW` REQUEST HEADER as
290
- raw minified JSON (no base64):
321
+ If you register a key that has a live client here, you get that
322
+ client again, with the same `client_id`. If the `client_id` is
323
+ new, the account holder unlinked your old client: ask them before
324
+ you continue. A body that is not correct gets
325
+ `400 { "error": "invalid_client_metadata", "error_description" }`.
326
+
327
+ Registration can have an Equihash proof-of-work toll. Then the
328
+ request gets `402 pow_required` with
329
+ `WWW-Authenticate: Kiosk-PoW realm="<issuer>"` and a `challenges`
330
+ array in the body. The toll is bound to the key that you
331
+ register. Solve EVERY challenge. Then send the SAME body again,
332
+ with the proofs in a `Kiosk-PoW` REQUEST HEADER as raw minified
333
+ JSON (no base64):
291
334
 
292
335
  Kiosk-PoW: {"challenge":{…},"nonce":{"indices":[…],"header_nonce":0}}
293
336
 
294
- Send N proofs as a JSON array in that one header, or as one repeated
295
- `Kiosk-PoW` header line per proof. The proof NEVER travels in the
296
- request body: a body `pow` field is ignored, and changing the body
297
- invalidates the proofs. The same header answers a `402 pow_required`
298
- on the wire verbs too, and most of them are GETs, which have no body
299
- to carry a proof. The ONE endpoint under `#{endpoint}` that is never
300
- tolled is `GET #{endpoint}/schema`: it is public, it is served from
301
- memory, and a toll needs an identity to charge.
337
+ Send N proofs as a JSON array in that one header, or as one
338
+ `Kiosk-PoW` header line for each proof. A proof is never in the
339
+ request body: the operator ignores a body `pow` field, and a
340
+ changed body makes the proofs incorrect. The same header answers a
341
+ `402 pow_required` on the wire verbs too. Most of them are GETs,
342
+ which have no body. The ONE endpoint under `#{endpoint}` that has
343
+ no toll is `GET #{endpoint}/schema`: it is public, the operator
344
+ serves it from memory, and a toll needs an identity to charge.
302
345
 
303
346
  Do not write your own Equihash solver. The verifier is exact about
304
- the seed construction, the index width and the tree ordering, and a
305
- mismatch comes back `403 forbidden` with no indication of which check
306
- failed. Fetch the reference solver from
307
- `#{PowGate::POW_SOLVER_URL}` — the same file that 403's `hint` names.
308
- That URL is unversioned and always serves the CURRENT solver, so
309
- check its SHA-256 against the content-addressed pin the wire skill
310
- above publishes before you execute it.
347
+ the seed construction, the index width and the tree order. A
348
+ mismatch gets `403 forbidden`, and the answer does not say which
349
+ check failed. Get the reference solver from
350
+ `#{PowGate::POW_SOLVER_URL}`. The 403 `hint` names the same file.
351
+ That URL has no version and always serves the CURRENT solver.
352
+ Before you run it, compare its SHA-256 with the pin that the wire
353
+ skill above publishes.
311
354
 
312
355
  ## Claim ceremony
313
356
 
314
- User-claimed binding (RFC 8628 wire) — binds YOUR key to the
315
- account holder's existing account; single-use, short-TTL codes:
316
-
317
- 1. `POST #{endpoint}/oauth/device_authorization`
318
- (form-encoded: `client_id`, `public_key` — required) →
319
- `{ device_code, user_code, verification_uri, expires_in, interval }`
320
- 2. Show the holder: "open <verification_uri>, enter <user_code>".
357
+ A client assertion is a compact JWT that your registered key
358
+ signs: `alg` EdDSA for an Ed25519 key, ES256 for a P-256 key. Its payload is `{iss, sub, aud, exp, jti}`: `iss` and `sub`
359
+ are your `client_id`, `aud` is the origin you connected to (take it
360
+ from your own request URL), `exp` is at most 300 seconds from now,
361
+ and `jti` is a new unique id. Send it as `client_assertion`, with
362
+ `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
363
+ The operator accepts each `jti` one time.
364
+
365
+ Claim binding (RFC 8628) binds YOUR client to the account holder's
366
+ existing account. The codes are single-use and short-lived:
367
+
368
+ 1. `POST #{urls[:device_authorization]}` (form-encoded:
369
+ `client_assertion_type`, `client_assertion`) →
370
+ `{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }`
371
+ 2. Tell the holder: "open <verification_uri>, enter <user_code>".
321
372
  They approve in their own signed-in browser session.
322
- 3. Poll `POST #{endpoint}/oauth/token` (form-encoded:
373
+ 3. Poll `POST #{urls[:token]}` (form-encoded:
323
374
  `grant_type=urn:ietf:params:oauth:grant-type:device_code`,
324
- `device_code`, and `signed` — the same challenge-response
325
- possession proof as register/login, for the SAME key from
326
- step 1) → `{ access_token, token_type, expires_in }`.
327
- No binding happens without a valid possession proof.
375
+ `device_code`, `client_assertion_type`, `client_assertion`),
376
+ with a `DPoP` header as in Exchange. When the holder approves,
377
+ the answer is a token under the holder's account.
328
378
 
329
- Link flow (Kiosk extension, mirror direction): the holder mints a
330
- code on the provider's site and pastes it to you; redeem with
331
- `POST #{endpoint}/auth/claim` `{ code, public_key, signed }`
332
- → `201 { agent_id, user_id, access_token }`.
379
+ Link flow (the opposite direction): the holder makes a code on the
380
+ provider's site and gives it to you. Send
381
+ `POST #{urls[:claim]}` `{ code, client_assertion_type, client_assertion }`
382
+ → `204 No Content`. Then get a token as in Exchange.
333
383
 
334
384
  Where registration has a toll, the link-code claim and the FIRST
335
385
  token poll of each `device_code` have the same toll. It comes
336
- before the code is looked up. Solve it as for register and send the
337
- SAME request again with the `Kiosk-PoW` header. Later polls of the
338
- same `device_code` are free.
386
+ before the operator reads the code. Solve it as for registration
387
+ and send the SAME request again with the `Kiosk-PoW` header. Later
388
+ polls of the same `device_code` have no toll.
339
389
 
340
- A fresh key becomes a linked assistant account under the holder's
341
- account; an already-registered key is re-bound (its reputation
342
- carries over — claiming never resets an identity).
390
+ A binding keeps your `client_id` and your reputation. Your client
391
+ moves to the holder's account and gets the holder's role.
343
392
 
344
393
  ## Exchange
345
394
 
346
- There is no separate exchange step: registration, the claim
347
- ceremony and the link redeem each return the access token
348
- directly. Refresh by logging in with your key —
349
- `POST #{urls[:login]}` `{ public_key, signed }` — the ceremony
350
- never repeats.
395
+ `POST #{urls[:token]}`, form-encoded:
396
+ `grant_type=client_credentials`, `client_assertion_type`,
397
+ `client_assertion`. Add a `DPoP` header (RFC 9449): a JWS with
398
+ `typ` `dpop+jwt`, the `alg` of your key and the public `jwk` of your
399
+ registered key, that the key signs, with the payload
400
+ `{jti, htm: "POST", htu: "#{urls[:token]}", iat}`. A token
401
+ request has no `ath`.
402
+ → `200 { access_token, token_type: "DPoP", expires_in, scope }`.
403
+ `scope` is your role, and it is there only when you have a role.
404
+
405
+ To get a new token, do this request again: no ceremony repeats.
406
+ If you get `invalid_client`, sign a new assertion one time. If
407
+ that also fails, register your key again.
351
408
 
352
409
  ## Use the access_token
353
410
 
354
- Send `Authorization: Bearer <access_token>` to the wire verbs
355
- under `#{endpoint}` — every query, every action, and `pay`. Tokens
356
- are RS256 JWTs verifiable against the JWKS above.
357
- `GET #{endpoint}/schema` is the exception: it is PUBLIC, so read
358
- the catalogue before you register if you like.
411
+ Send `Authorization: DPoP <access_token>` to the wire verbs
412
+ under `#{endpoint}` (every query, every action, and `pay`) with a
413
+ `DPoP` header: a proof (RFC 9449) that your registered key signs
414
+ for that method and URL, with `ath`, the hash of the token. A
415
+ token is bound to that key and works with no other key. Tokens are
416
+ EdDSA JWTs that the JWKS above verifies.
417
+ `GET #{endpoint}/schema` is the exception: it is PUBLIC, so you
418
+ can read the catalogue before you register.
359
419
 
360
420
  ## Errors
361
421
 
@@ -367,47 +427,51 @@ module Kiosk
367
427
  "title":"Proof-of-work required","status":402,
368
428
  "detail":"proof-of-work required","code":"pow_required"}
369
429
 
370
- Branch on `code` — a FLAT member of the document, not nested — from
371
- the closed vocabulary (`unauthenticated`, `not_found`, `conflict`,
372
- `pow_required`, …); `type` is `https://kiosk.tech/problems/<code>`
373
- and names the same fact. Some codes add members of their own, such
374
- as the `challenges` array on a `402 pow_required`.
375
- - Three codes say "it is not here" and they mean three different
376
- things. `404 verb_not_found` — no verb by that NAME is registered
377
- here; `hint` lists the ones that are, so re-read the catalogue and
378
- call something that exists. `404 not_found` — the verb is real and
379
- an ARGUMENT addressed something absent; the answer is final, so
380
- stop. `501 module_not_served` — this origin does not serve that
381
- optional module at all (binding, payment, KYC, the event stream;
382
- `detail` names which); do not retry, and fall back to what you
383
- would do at an operator that never offered it.
384
- - The claim ceremony's OAuth endpoints use the OAuth error shape
385
- `{ error, error_description }` — a documented exception, and the
386
- only one, with two carve-outs that are ordinary problem documents:
387
- an origin that does not serve binding at all answers
388
- `501 module_not_served` there, because none of the OAuth codes
389
- means it, and the toll on the token poll answers as the toll does
390
- everywhere (`402 pow_required`, and its refusals of a proof). The
391
- OAuth vocabulary is closed at eight. Six describe the
392
- ceremony (RFC 8628 §3.5): `authorization_pending`, `slow_down`,
393
- `expired_token`, `access_denied`, `invalid_grant`,
394
- `invalid_client`. Two are request-level (RFC 6749 §5.2):
395
- `invalid_request` — a required parameter absent or malformed, or
396
- one the request must not carry, such as a `role`/`scope` on the
397
- device-authorization call — and `unsupported_grant_type` for any
398
- grant but the device-code one. Everything answers `400` except
399
- `invalid_client`, which answers `401`.
430
+ Use `code` to decide what to do. It is a FLAT member of the
431
+ document, not nested, from the closed vocabulary
432
+ (`unauthenticated`, `not_found`, `conflict`, `pow_required`, …);
433
+ `type` is `https://kiosk.tech/problems/<code>` and names the same
434
+ fact. Some codes add members of their own, such as the
435
+ `challenges` array on a `402 pow_required`.
436
+ - Three codes say "it is not here", and each has a different
437
+ meaning. `404 verb_not_found`: no verb with that NAME is
438
+ registered here; `hint` lists the verbs that are, so read the
439
+ catalogue again and call a verb that exists. `404 not_found`:
440
+ the verb is real and an ARGUMENT names something that is not
441
+ there; the answer is final, so stop. `501 module_not_served`:
442
+ this origin does not serve that optional module (binding,
443
+ payment, KYC, the event stream; `detail` names which); do not
444
+ retry, and do what you would do at an operator that never
445
+ offered it.
446
+ - The OAuth endpoints (`/oauth/*`) use the OAuth error shape
447
+ `{ error, error_description }`. Two answers there are problem
448
+ documents: `501 module_not_served` where the origin does not
449
+ serve binding, and the toll (`402 pow_required` and its
450
+ refusals of a proof). Registration has one code,
451
+ `invalid_client_metadata`. The token and device-authorization
452
+ codes are a closed set of nine: `invalid_request`,
453
+ `invalid_client`, `invalid_grant`, `unsupported_grant_type`,
454
+ `invalid_dpop_proof` (RFC 9449), and the ceremony codes
455
+ `authorization_pending`, `slow_down`, `expired_token`,
456
+ `access_denied` (RFC 8628 §3.5). Every code answers `400`,
457
+ except `invalid_client`, which answers `401`.
400
458
 
401
459
  ## Revocation
402
460
 
403
- - Credential layer: `POST #{urls[:revoke]}` (Bearer) revokes every
404
- outstanding token for your identity and returns a fresh one.
405
- - Registration layer (unlink): the account holder — or the
406
- provider — deactivates a key's binding (`POST
407
- #{endpoint}/auth/unlink`, session-authenticated, or the
408
- provider's linked-assistants page). An unlinked key stops
409
- verifying and can no longer log in; it does NOT revert to a
410
- standalone account.
461
+ - Key rotation: `POST #{urls[:rotate]}`
462
+ `{ client_assertion_type, client_assertion, new_client_assertion, jwks }`
463
+ puts the new key in `jwks` on your client. Your registered key
464
+ signs `client_assertion`, with `new_key`, the RFC 7638
465
+ thumbprint of the new key, in its payload. The new key signs
466
+ `new_client_assertion` for the same `client_id`. The answer is
467
+ your client information with the new key. Your `client_id` and
468
+ your reputation stay. Every earlier token stops. Get a new token
469
+ with the new key.
470
+ - Unlink: the account holder, or the provider, deactivates your
471
+ client (`POST #{endpoint}/auth/unlink`, authenticated by the
472
+ holder's session, or the provider's linked-assistants page).
473
+ Its tokens stop, and the token endpoint answers it
474
+ `invalid_client`. It does NOT become a standalone account.
411
475
  MARKDOWN
412
476
  end
413
477
  end