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
@@ -1,31 +1,45 @@
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
+ # The one scope value. An AI assistant does not choose its role: the
37
+ # token request grants the role of its assistant account, whatever scope
38
+ # it names (RFC 6749 Section 3.3).
39
+ SCOPES = %w[default].freeze
40
+
41
+ ARD_API = 'The Kiosk discovery document of this origin: the endpoint, the sign-in and the catalogue. ' \
42
+ 'Read it with the Kiosk skill.'
29
43
 
30
44
  # `/.well-known/kiosk.json`.
31
45
  def self.build(base_url:, config: Kiosk.configuration)
@@ -50,64 +64,98 @@ module Kiosk
50
64
  base = base_url.to_s.chomp('/')
51
65
 
52
66
  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?
67
+ lines.push('', "Authorization: #{AUTHORIZATION[:protocols].join(', ')}")
68
+ lines.push('', "Skills: #{config.skill_url}") if Shared.skill?(config)
56
69
 
57
70
  "#{lines.join("\n")}\n"
58
71
  end
59
72
 
60
- # agents.json v1.0; Kiosk's pointers ride the `x-kiosk` extension.
73
+ # agents.json v1.0.
61
74
  def self.agents_json(base_url:, config: Kiosk.configuration)
62
75
  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)
76
+ doc = { '$schema': AGENTS_SCHEMA, version: AGENTS_VERSION, standard: AGENTS_STANDARD,
77
+ site: { name: site_name(config), url: base_url.to_s.chomp('/') },
78
+ authorization: AUTHORIZATION }
79
+ doc[:skills] = [{ url: config.skill_url, description: SKILL_DESCRIPTION }] if Shared.skill?(config)
69
80
  doc
70
81
  end
71
82
 
72
- # `/.well-known/agent-configuration`: the kiosk-pop auth endpoints.
73
- def self.agent_configuration(base_url:, config: Kiosk.configuration)
83
+ # `/.well-known/oauth-authorization-server` (RFC 8414): registration,
84
+ # tokens, and the device-code grant where the origin serves binding.
85
+ def self.oauth_authorization_server(base_url:, config: Kiosk.configuration)
74
86
  Shared.validate_issuer!(config)
75
87
  base = base_url.to_s.chomp('/')
76
88
  endpoint = base + config.mount_path
89
+ doc = { issuer: Kiosk.current_issuer, registration_endpoint: "#{endpoint}/oauth/register",
90
+ token_endpoint: "#{endpoint}/oauth/token" }
91
+ doc[:device_authorization_endpoint] = "#{endpoint}/oauth/device_authorization" if config.serve_account_binding
92
+ # No authorization endpoint, so no response type.
93
+ doc.merge(jwks_uri: "#{endpoint}/.well-known/jwks.json", response_types_supported: [], scopes_supported: SCOPES,
94
+ grant_types_supported: Client.grant_types(config), **Shared.client_authentication,
95
+ service_documentation: "#{base}/auth.md", dpop_signing_alg_values_supported: AssistantKey.algorithms)
96
+ end
97
+
98
+ # `/.well-known/oauth-protected-resource` (RFC 9728). RFC 9728 §3.3 binds a
99
+ # document at this path to the origin, so `resource` is the origin.
100
+ def self.oauth_protected_resource(base_url:, config: Kiosk.configuration)
101
+ Shared.validate_issuer!(config)
102
+ base = base_url.to_s.chomp('/')
103
+ { resource: base, authorization_servers: [Kiosk.current_issuer], scopes_supported: SCOPES,
104
+ bearer_methods_supported: ['header'],
105
+ dpop_signing_alg_values_supported: AssistantKey.algorithms, dpop_bound_access_tokens_required: true,
106
+ resource_documentation: "#{base}/auth.md" }
107
+ end
77
108
 
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
- }
109
+ # `/.well-known/agent-skills/index.json` (Agent Skills Discovery v0.2.0):
110
+ # the pinned skill and its digest. nil without a `skill_sha256`.
111
+ def self.agent_skills_index(config: Kiosk.configuration)
112
+ return unless Shared.skill?(config) && config.skill_sha256
113
+
114
+ { '$schema': SKILLS_INDEX_SCHEMA,
115
+ skills: [{ name: SKILL_NAME, type: 'skill-md', description: SKILL_DESCRIPTION,
116
+ url: config.skill_url, digest: "sha256:#{config.skill_sha256}" }] }
117
+ end
118
+
119
+ # `/.well-known/ard.json` (Agentic Resource Discovery v0.91), also served
120
+ # at `/.well-known/ai-catalog.json` (AI Catalog 1.0): this origin's
121
+ # kiosk.json and the skill that reads it.
122
+ def self.ard(base_url:, config: Kiosk.configuration)
123
+ Shared.validate_issuer!(config)
124
+ base = base_url.to_s.chomp('/')
125
+ urn = "urn:air:#{URI.parse(base).host}:kiosk"
126
+ entries = [{ identifier: "#{urn}:api", displayName: "#{site_name(config)} Kiosk endpoint",
127
+ type: 'application/json', url: "#{base}/.well-known/kiosk.json", description: ARD_API }]
128
+ if Shared.skill?(config)
129
+ entries << { identifier: "#{urn}:skill", displayName: SKILL_NAME, type: 'application/agent-skills+md',
130
+ url: config.skill_url, description: SKILL_DESCRIPTION }
131
+ end
132
+ { specVersion: AI_CATALOG_VERSION, entries: entries }
85
133
  end
86
134
 
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.
135
+ # `/.well-known/api-catalog` (RFC 9727): every verb as an `item` of the
136
+ # catalog, its method in the `kiosk-method` extension attribute, and the
137
+ # two service descriptions on the endpoint. Composed from configuration
138
+ # and the registries only, never a query.
90
139
  def self.api_catalog(base_url:, config: Kiosk.configuration)
91
140
  Shared.validate_issuer!(config)
92
141
  base = base_url.to_s.chomp('/')
93
142
  endpoint = base + config.mount_path
94
143
  modules = Array(config.capabilities)
95
144
 
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 }] }
145
+ linkset = [{ anchor: "#{base}/.well-known/api-catalog",
146
+ item: Catalog.verb_items(endpoint) + Catalog.module_items(endpoint, modules) }]
147
+ descs = Catalog.service_descs(endpoint, modules, config)
148
+ linkset << { anchor: endpoint, 'service-desc': descs } if descs.any?
149
+ { linkset: linkset }
101
150
  end
102
151
 
103
- # `/auth.md`: the auth methods in auth.md's vocabulary and section order.
152
+ # `/auth.md`: the auth methods in auth.md's section order.
104
153
  def self.auth_md(base_url:, config: Kiosk.configuration)
105
154
  Shared.validate_issuer!(config)
106
155
  base = base_url.to_s.chomp('/')
107
156
  endpoint = base + config.mount_path
108
157
 
109
- AuthMd.render(site: site_name(config), base: base, endpoint: endpoint,
110
- urls: Shared.auth_urls(endpoint), skill_line: Shared.skill_discover_line(config))
158
+ AuthMd.render(site: site_name(config), base: base, endpoint: endpoint, urls: Shared.auth_urls(endpoint), config: config)
111
159
  end
112
160
 
113
161
  # The owner's name, else the issuer's host. Also titles the OpenAPI document.
@@ -137,60 +185,44 @@ module Kiosk
137
185
  }
138
186
  end
139
187
 
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
- }
188
+ # RFC 8414: how a client authenticates at the token endpoint.
189
+ def client_authentication
190
+ { token_endpoint_auth_methods_supported: [Client::AUTH_METHOD],
191
+ token_endpoint_auth_signing_alg_values_supported: AssistantKey.algorithms }
148
192
  end
149
193
 
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
- }
194
+ # Every URL, also the binding ones: where binding is not served they answer 501.
195
+ def auth_section(endpoint)
196
+ auth_urls(endpoint).transform_keys { |step| :"#{step}_url" }
158
197
  end
159
198
 
160
199
  def auth_urls(endpoint)
161
200
  {
162
- challenge: "#{endpoint}/auth/challenge",
163
- register: "#{endpoint}/auth/register",
164
- login: "#{endpoint}/auth/login",
165
- revoke: "#{endpoint}/auth/revoke"
201
+ registration: "#{endpoint}/oauth/register",
202
+ token: "#{endpoint}/oauth/token",
203
+ rotate: "#{endpoint}/auth/rotate",
204
+ device_authorization: "#{endpoint}/oauth/device_authorization",
205
+ claim: "#{endpoint}/auth/claim"
166
206
  }
167
207
  end
168
208
 
169
- def pay_served?(config)
170
- Array(config.capabilities).map(&:to_s).include?('pay')
171
- end
172
-
173
209
  def host_of(issuer)
174
- require 'uri'
175
210
  URI.parse(issuer.to_s).host || issuer.to_s
176
211
  rescue URI::InvalidURIError
177
212
  issuer.to_s
178
213
  end
179
214
 
180
215
  def skill_discover_line(config)
181
- url = config.skill_url
182
- return '' if url.nil? || url.to_s.empty?
216
+ return '' unless skill?(config)
183
217
 
184
218
  sha = config.skill_sha256
185
- line = "- Wire skill for AI assistants: `#{url}`"
219
+ line = "- Skill for AI assistants: #{config.skill_url}"
186
220
  line += " (sha256 `#{sha}`)" if sha && !sha.to_s.empty?
187
221
  line
188
222
  end
189
223
 
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' }]
224
+ def skill?(config)
225
+ !config.skill_url.to_s.empty?
194
226
  end
195
227
 
196
228
  def validate_issuer!(config)
@@ -199,11 +231,11 @@ module Kiosk
199
231
  raise ArgumentError,
200
232
  'Kiosk.configuration.issuer must be set before serving ' \
201
233
  '/.well-known/kiosk.json — the issuer ' \
202
- 'is the AP2 mandate `iss` anchor'
234
+ 'is the origin a token or a checkout names'
203
235
  end
204
236
  end
205
237
 
206
- # The api-catalog's linkset items, in document order.
238
+ # The api-catalog's links.
207
239
  module Catalog
208
240
  module_function
209
241
 
@@ -213,7 +245,8 @@ module Kiosk
213
245
  return [] unless modules.include?('schema')
214
246
 
215
247
  version = SchemaDocument.digest(config: config)
216
- %w[schema openapi.json].map { |path| { href: "#{endpoint}/#{path}?v=#{version}", rel: 'service-desc' } }
248
+ [{ href: "#{endpoint}/schema?v=#{version}", type: 'application/json' },
249
+ { href: "#{endpoint}/openapi.json?v=#{version}", type: OpenApi::CONTENT_TYPE }]
217
250
  end
218
251
 
219
252
  # Sorted, so the document is byte-stable across boots.
@@ -223,139 +256,169 @@ module Kiosk
223
256
  end
224
257
 
225
258
  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
259
+ modules.include?('pay') ? [verb_item(endpoint, 'pay', 'POST')] : []
229
260
  end
230
261
 
231
262
  # RFC 9264 §4.2.4.3: an extension attribute is an array of strings.
232
263
  def verb_item(endpoint, name, method)
233
- { href: "#{endpoint}/#{name}", rel: 'item', 'kiosk-method': [method] }
264
+ { href: "#{endpoint}/#{name}", 'kiosk-method': [method] }
234
265
  end
235
266
  end
236
267
 
237
268
  # The `/auth.md` document.
238
269
  # rubocop:disable-next Metrics/ModuleLength -- one Markdown document; its lines are prose
239
270
  module AuthMd
240
- def self.render(site:, base:, endpoint:, urls:, skill_line:)
271
+ # This operator also accepts a P-256 key, or says nothing.
272
+ def self.es256_line(config)
273
+ return '' unless config.assistant_algorithms.include?('ES256')
274
+
275
+ ' This provider also accepts a P-256 key (ES256).'
276
+ end
277
+
278
+ def self.render(site:, base:, endpoint:, urls:, config:)
241
279
  <<~MARKDOWN
242
- # #{site} — agent authentication
280
+ # auth.md — #{site}
243
281
 
244
282
  How AI assistants authenticate against this provider's Kiosk
245
- endpoint (`#{endpoint}`). Wire contract: the Kiosk specification
246
- (https://kiosk.tech/specification.html).
283
+ endpoint (`#{endpoint}`). Protocol: the Kiosk specification
284
+ (https://kiosk.tech/specification.html). This file follows the
285
+ auth.md section order. Registration and tokens use OAuth 2.0
286
+ standards: RFC 7591, RFC 7523, RFC 9449 and RFC 8628.
247
287
 
248
288
  ## Discover
249
289
 
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}
290
+ - This file: #{base}/auth.md
291
+ - Kiosk discovery document: #{base}/.well-known/kiosk.json
292
+ - OAuth metadata of this authorization server (RFC 8414): #{base}/.well-known/oauth-authorization-server
293
+ - OAuth metadata of this protected resource (RFC 9728): #{base}/.well-known/oauth-protected-resource
294
+ - Verb catalogue (PUBLIC, no token): #{endpoint}/schema
295
+ - Token-verification keys (JWKS): #{endpoint}/.well-known/jwks.json
296
+ #{Shared.skill_discover_line(config)}
256
297
 
257
298
  ## Pick a method
258
299
 
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.
300
+ - **Open registration** — supported. You register your own
301
+ public key (RFC 7591). No human account is necessary. You
302
+ authenticate each token request with a client assertion that
303
+ your key signs (`private_key_jwt`, RFC 7523).
304
+ - **Claim ceremony** — supported. It binds your registered client
305
+ to an EXISTING account after its holder approves in their own
306
+ browser session (RFC 8628).
307
+ - **Link code** — supported. The account holder makes a
308
+ single-use code on the provider's site and gives it to you.
270
309
  - **Identity assertion (ID-JAG)** — not supported (planned).
271
310
 
272
311
  ## Register
273
312
 
274
- kiosk-pop self-registration (anonymous class):
313
+ 1. Make one Ed25519 key for this provider. Keep the private
314
+ key.#{es256_line(config)}
315
+ 2. `POST #{urls[:registration]}` with `Content-Type: application/json`:
316
+
317
+ {"jwks":{"keys":[{"kty":"OKP","crv":"Ed25519","x":"…"}]},
318
+ "token_endpoint_auth_method":"private_key_jwt"}
275
319
 
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 }`
320
+ → `201 { client_id, client_id_issued_at, jwks,
321
+ token_endpoint_auth_method, grant_types, dpop_bound_access_tokens }`
322
+ 3. Keep `client_id`. Registration gives no token.
282
323
 
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):
324
+ If you register a key that has a live client here, you get that
325
+ client again, with the same `client_id`. If the `client_id` is
326
+ new, the account holder unlinked your old client: ask them before
327
+ you continue. A body that is not correct gets
328
+ `400 { "error": "invalid_client_metadata", "error_description" }`.
329
+
330
+ Registration can have an Equihash proof-of-work toll. Then the
331
+ request gets `402 pow_required` with
332
+ `WWW-Authenticate: Kiosk-PoW realm="<issuer>"` and a `challenges`
333
+ array in the body. The toll is bound to the key that you
334
+ register. Solve EVERY challenge. Then send the SAME body again,
335
+ with the proofs in a `Kiosk-PoW` REQUEST HEADER as raw minified
336
+ JSON (no base64):
291
337
 
292
338
  Kiosk-PoW: {"challenge":{…},"nonce":{"indices":[…],"header_nonce":0}}
293
339
 
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.
340
+ Send N proofs as a JSON array in that one header, or as one
341
+ `Kiosk-PoW` header line for each proof. A proof is never in the
342
+ request body: the operator ignores a body `pow` field, and a
343
+ changed body makes the proofs incorrect. The same header answers a
344
+ `402 pow_required` on the verbs too. Most of them are GETs,
345
+ which have no body. The ONE endpoint under `#{endpoint}` that has
346
+ no toll is `GET #{endpoint}/schema`: it is public, the operator
347
+ serves it from memory, and a toll needs an identity to charge.
302
348
 
303
349
  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.
350
+ the seed construction, the index width and the tree order. A
351
+ mismatch gets `403 forbidden`, and the answer does not say which
352
+ check failed. Get the reference solver from
353
+ `#{PowGate::POW_SOLVER_URL}`. The 403 `hint` names the same file.
354
+ That URL has no version and always serves the CURRENT solver.
355
+ Before you run it, compare its SHA-256 with the pin that the
356
+ skill above publishes.
311
357
 
312
358
  ## Claim ceremony
313
359
 
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>".
360
+ A client assertion is a compact JWT that your registered key
361
+ signs: `alg` EdDSA for an Ed25519 key, ES256 for a P-256 key. Its payload is `{iss, sub, aud, exp, jti}`: `iss` and `sub`
362
+ are your `client_id`, `aud` is the origin you connected to (take it
363
+ from your own request URL), `exp` is at most 300 seconds from now,
364
+ and `jti` is a new unique id. Send it as `client_assertion`, with
365
+ `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
366
+ The operator accepts each `jti` one time.
367
+
368
+ Claim binding (RFC 8628) binds YOUR client to the account holder's
369
+ existing account. The codes are single-use and short-lived:
370
+
371
+ 1. `POST #{urls[:device_authorization]}` (form-encoded:
372
+ `client_assertion_type`, `client_assertion`) →
373
+ `{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }`
374
+ 2. Tell the holder: "open <verification_uri>, enter <user_code>".
321
375
  They approve in their own signed-in browser session.
322
- 3. Poll `POST #{endpoint}/oauth/token` (form-encoded:
376
+ 3. Poll `POST #{urls[:token]}` (form-encoded:
323
377
  `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.
378
+ `device_code`, `client_assertion_type`, `client_assertion`),
379
+ with a `DPoP` header as in Exchange. When the holder approves,
380
+ the answer is a token under the holder's account.
328
381
 
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 }`.
382
+ Link flow (the opposite direction): the holder makes a code on the
383
+ provider's site and gives it to you. Send
384
+ `POST #{urls[:claim]}` `{ code, client_assertion_type, client_assertion }`
385
+ → `204 No Content`. Then get a token as in Exchange.
333
386
 
334
387
  Where registration has a toll, the link-code claim and the FIRST
335
388
  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.
389
+ before the operator reads the code. Solve it as for registration
390
+ and send the SAME request again with the `Kiosk-PoW` header. Later
391
+ polls of the same `device_code` have no toll.
339
392
 
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).
393
+ A binding keeps your `client_id` and your reputation. Your client
394
+ moves to the holder's account and gets the holder's role.
343
395
 
344
396
  ## Exchange
345
397
 
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.
398
+ `POST #{urls[:token]}`, form-encoded:
399
+ `grant_type=client_credentials`, `client_assertion_type`,
400
+ `client_assertion`. Add a `DPoP` header (RFC 9449): a JWS with
401
+ `typ` `dpop+jwt`, the `alg` of your key and the public `jwk` of your
402
+ registered key, that the key signs, with the payload
403
+ `{jti, htm: "POST", htu: "#{urls[:token]}", iat}`. A token
404
+ request has no `ath`.
405
+ → `200 { access_token, token_type: "DPoP", expires_in, scope }`.
406
+ `scope` is your role, and it is there only when you have a role.
407
+
408
+ To get a new token, do this request again: no ceremony repeats.
409
+ If you get `invalid_client`, sign a new assertion one time. If
410
+ that also fails, register your key again.
351
411
 
352
412
  ## Use the access_token
353
413
 
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.
414
+ Send `Authorization: DPoP <access_token>` to the verbs
415
+ under `#{endpoint}` (every query, every action, and `pay`) with a
416
+ `DPoP` header: a proof (RFC 9449) that your registered key signs
417
+ for that method and URL, with `ath`, the hash of the token. A
418
+ token is bound to that key and works with no other key. Tokens are
419
+ EdDSA JWTs that the JWKS above verifies.
420
+ `GET #{endpoint}/schema` is the exception: it is PUBLIC, so you
421
+ can read the catalogue before you register.
359
422
 
360
423
  ## Errors
361
424
 
@@ -367,47 +430,51 @@ module Kiosk
367
430
  "title":"Proof-of-work required","status":402,
368
431
  "detail":"proof-of-work required","code":"pow_required"}
369
432
 
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`.
433
+ Use `code` to decide what to do. It is a FLAT member of the
434
+ document, not nested, from the closed vocabulary
435
+ (`unauthenticated`, `not_found`, `conflict`, `pow_required`, …);
436
+ `type` is `https://kiosk.tech/problems/<code>` and names the same
437
+ fact. Some codes add members of their own, such as the
438
+ `challenges` array on a `402 pow_required`.
439
+ - Three codes say "it is not here", and each has a different
440
+ meaning. `404 verb_not_found`: no verb with that NAME is
441
+ registered here; `hint` lists the verbs that are, so read the
442
+ catalogue again and call a verb that exists. `404 not_found`:
443
+ the verb is real and an ARGUMENT names something that is not
444
+ there; the answer is final, so stop. `501 module_not_served`:
445
+ this origin does not serve that optional module (binding,
446
+ payment, KYC, the event stream; `detail` names which); do not
447
+ retry, and do what you would do at an operator that never
448
+ offered it.
449
+ - The OAuth endpoints (`/oauth/*`) use the OAuth error shape
450
+ `{ error, error_description }`. Two answers there are problem
451
+ documents: `501 module_not_served` where the origin does not
452
+ serve binding, and the toll (`402 pow_required` and its
453
+ refusals of a proof). Registration has one code,
454
+ `invalid_client_metadata`. The token and device-authorization
455
+ codes are a closed set of nine: `invalid_request`,
456
+ `invalid_client`, `invalid_grant`, `unsupported_grant_type`,
457
+ `invalid_dpop_proof` (RFC 9449), and the ceremony codes
458
+ `authorization_pending`, `slow_down`, `expired_token`,
459
+ `access_denied` (RFC 8628 §3.5). Every code answers `400`,
460
+ except `invalid_client`, which answers `401`.
400
461
 
401
462
  ## Revocation
402
463
 
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.
464
+ - Key rotation: `POST #{urls[:rotate]}`
465
+ `{ client_assertion_type, client_assertion, new_client_assertion, jwks }`
466
+ puts the new key in `jwks` on your client. Your registered key
467
+ signs `client_assertion`, with `new_key`, the RFC 7638
468
+ thumbprint of the new key, in its payload. The new key signs
469
+ `new_client_assertion` for the same `client_id`. The answer is
470
+ your client information with the new key. Your `client_id` and
471
+ your reputation stay. Every earlier token stops. Get a new token
472
+ with the new key.
473
+ - Unlink: the account holder, or the provider, deactivates your
474
+ client (`POST #{endpoint}/auth/unlink`, authenticated by the
475
+ holder's session, or the provider's linked-assistants page).
476
+ Its tokens stop, and the token endpoint answers it
477
+ `invalid_client`. It does NOT become a standalone account.
411
478
  MARKDOWN
412
479
  end
413
480
  end