kiosk-server 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- metadata +349 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
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.
|
|
10
|
+
module WellKnown
|
|
11
|
+
DOCUMENT_VERSION = '1.0'
|
|
12
|
+
|
|
13
|
+
AGENTS_VERSION = '1.0'
|
|
14
|
+
AGENTS_STANDARD = 'https://agents-txt.com/standard'
|
|
15
|
+
|
|
16
|
+
# Modules with one fixed endpoint; the catalog links every verb separately.
|
|
17
|
+
MODULE_ENDPOINTS = { 'pay' => 'pay' }.freeze
|
|
18
|
+
|
|
19
|
+
PAYMENTS = {
|
|
20
|
+
ap2: { description: 'Mandate-trust layer with VC presentations' },
|
|
21
|
+
required: true
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
AUTHORIZATION = {
|
|
25
|
+
protocols: %w[agent-auth auth-md],
|
|
26
|
+
discovery: '/.well-known/agent-configuration',
|
|
27
|
+
identity: 'required'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# `/.well-known/kiosk.json`.
|
|
31
|
+
def self.build(base_url:, config: Kiosk.configuration)
|
|
32
|
+
Shared.validate_issuer!(config)
|
|
33
|
+
endpoint = base_url.to_s.chomp('/') + config.mount_path
|
|
34
|
+
kiosk = Shared.kiosk_document(endpoint, config)
|
|
35
|
+
# Only when a topic is registered: advertise what is served.
|
|
36
|
+
kiosk[:events_url] = "#{endpoint.sub(/\Ahttp/, 'ws')}/events" if Kiosk::Server::Events.known.any?
|
|
37
|
+
|
|
38
|
+
kiosk[:skill] = { url: config.skill_url, sha256: config.skill_sha256 } if config.skill_sha256
|
|
39
|
+
|
|
40
|
+
{ kiosk: kiosk }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.build_json(**)
|
|
44
|
+
JSON.generate(build(**))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# agents.txt v1.0 (agents-txt.com).
|
|
48
|
+
def self.agents_txt(base_url:, config: Kiosk.configuration)
|
|
49
|
+
Shared.validate_issuer!(config)
|
|
50
|
+
base = base_url.to_s.chomp('/')
|
|
51
|
+
|
|
52
|
+
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?
|
|
56
|
+
|
|
57
|
+
"#{lines.join("\n")}\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# agents.json v1.0; Kiosk's pointers ride the `x-kiosk` extension.
|
|
61
|
+
def self.agents_json(base_url:, config: Kiosk.configuration)
|
|
62
|
+
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)
|
|
69
|
+
doc
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# `/.well-known/agent-configuration`: the kiosk-pop auth endpoints.
|
|
73
|
+
def self.agent_configuration(base_url:, config: Kiosk.configuration)
|
|
74
|
+
Shared.validate_issuer!(config)
|
|
75
|
+
base = base_url.to_s.chomp('/')
|
|
76
|
+
endpoint = base + config.mount_path
|
|
77
|
+
|
|
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
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
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.
|
|
90
|
+
def self.api_catalog(base_url:, config: Kiosk.configuration)
|
|
91
|
+
Shared.validate_issuer!(config)
|
|
92
|
+
base = base_url.to_s.chomp('/')
|
|
93
|
+
endpoint = base + config.mount_path
|
|
94
|
+
modules = Array(config.capabilities)
|
|
95
|
+
|
|
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 }] }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `/auth.md`: the auth methods in auth.md's vocabulary and section order.
|
|
104
|
+
def self.auth_md(base_url:, config: Kiosk.configuration)
|
|
105
|
+
Shared.validate_issuer!(config)
|
|
106
|
+
base = base_url.to_s.chomp('/')
|
|
107
|
+
endpoint = base + config.mount_path
|
|
108
|
+
|
|
109
|
+
AuthMd.render(site: site_name(config), base: base, endpoint: endpoint,
|
|
110
|
+
urls: Shared.auth_urls(endpoint), skill_line: Shared.skill_discover_line(config))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The owner's name, else the issuer's host. Also titles the OpenAPI document.
|
|
114
|
+
def self.site_name(config)
|
|
115
|
+
name = config.owner.is_a?(Hash) ? config.owner[:name] : nil
|
|
116
|
+
return name if name && !name.to_s.empty?
|
|
117
|
+
|
|
118
|
+
Shared.host_of(Kiosk.current_issuer)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The parts the discovery documents share.
|
|
122
|
+
module Shared
|
|
123
|
+
module_function
|
|
124
|
+
|
|
125
|
+
def kiosk_document(endpoint, config)
|
|
126
|
+
{
|
|
127
|
+
version: DOCUMENT_VERSION,
|
|
128
|
+
endpoint: endpoint,
|
|
129
|
+
auth: auth_section(endpoint),
|
|
130
|
+
# The modules served, not the verb names; published here only.
|
|
131
|
+
capabilities: Array(config.capabilities),
|
|
132
|
+
# Versioned, so the catalog may be cached for a year at this URL.
|
|
133
|
+
schema_url: "#{endpoint}/schema?v=#{SchemaDocument.digest(config: config)}",
|
|
134
|
+
min_client: Kiosk::Protocol::MIN_CLIENT,
|
|
135
|
+
issuer: Kiosk.current_issuer,
|
|
136
|
+
owner: config.owner
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
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
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
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
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def auth_urls(endpoint)
|
|
161
|
+
{
|
|
162
|
+
challenge: "#{endpoint}/auth/challenge",
|
|
163
|
+
register: "#{endpoint}/auth/register",
|
|
164
|
+
login: "#{endpoint}/auth/login",
|
|
165
|
+
revoke: "#{endpoint}/auth/revoke"
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def pay_served?(config)
|
|
170
|
+
Array(config.capabilities).map(&:to_s).include?('pay')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def host_of(issuer)
|
|
174
|
+
require 'uri'
|
|
175
|
+
URI.parse(issuer.to_s).host || issuer.to_s
|
|
176
|
+
rescue URI::InvalidURIError
|
|
177
|
+
issuer.to_s
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def skill_discover_line(config)
|
|
181
|
+
url = config.skill_url
|
|
182
|
+
return '' if url.nil? || url.to_s.empty?
|
|
183
|
+
|
|
184
|
+
sha = config.skill_sha256
|
|
185
|
+
line = "- Wire skill for AI assistants: `#{url}`"
|
|
186
|
+
line += " (sha256 `#{sha}`)" if sha && !sha.to_s.empty?
|
|
187
|
+
line
|
|
188
|
+
end
|
|
189
|
+
|
|
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' }]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validate_issuer!(config)
|
|
197
|
+
return if config.issuer && !config.issuer.to_s.empty?
|
|
198
|
+
|
|
199
|
+
raise ArgumentError,
|
|
200
|
+
'Kiosk.configuration.issuer must be set before serving ' \
|
|
201
|
+
'/.well-known/kiosk.json — the issuer ' \
|
|
202
|
+
'is the AP2 mandate `iss` anchor'
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The api-catalog's linkset items, in document order.
|
|
207
|
+
module Catalog
|
|
208
|
+
module_function
|
|
209
|
+
|
|
210
|
+
# Versioned links: this pointer is short-lived, its targets are immutable.
|
|
211
|
+
# The only place the derived OpenAPI document is advertised.
|
|
212
|
+
def service_descs(endpoint, modules, config)
|
|
213
|
+
return [] unless modules.include?('schema')
|
|
214
|
+
|
|
215
|
+
version = SchemaDocument.digest(config: config)
|
|
216
|
+
%w[schema openapi.json].map { |path| { href: "#{endpoint}/#{path}?v=#{version}", rel: 'service-desc' } }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Sorted, so the document is byte-stable across boots.
|
|
220
|
+
def verb_items(endpoint)
|
|
221
|
+
Queries.known.sort.map { |name| verb_item(endpoint, name, 'GET') } +
|
|
222
|
+
Actions.known.sort.map { |name| verb_item(endpoint, name, 'POST') }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
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
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# RFC 9264 §4.2.4.3: an extension attribute is an array of strings.
|
|
232
|
+
def verb_item(endpoint, name, method)
|
|
233
|
+
{ href: "#{endpoint}/#{name}", rel: 'item', 'kiosk-method': [method] }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The `/auth.md` document.
|
|
238
|
+
# rubocop:disable-next Metrics/ModuleLength -- one Markdown document; its lines are prose
|
|
239
|
+
module AuthMd
|
|
240
|
+
def self.render(site:, base:, endpoint:, urls:, skill_line:)
|
|
241
|
+
<<~MARKDOWN
|
|
242
|
+
# #{site} — agent authentication
|
|
243
|
+
|
|
244
|
+
How AI assistants authenticate against this provider's Kiosk
|
|
245
|
+
endpoint (`#{endpoint}`). Wire contract: the Kiosk specification
|
|
246
|
+
(https://kiosk.tech/specification.html).
|
|
247
|
+
|
|
248
|
+
## Discover
|
|
249
|
+
|
|
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}
|
|
256
|
+
|
|
257
|
+
## Pick a method
|
|
258
|
+
|
|
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.
|
|
270
|
+
- **Identity assertion (ID-JAG)** — not supported (planned).
|
|
271
|
+
|
|
272
|
+
## Register
|
|
273
|
+
|
|
274
|
+
kiosk-pop self-registration (anonymous class):
|
|
275
|
+
|
|
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 }`
|
|
282
|
+
|
|
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):
|
|
291
|
+
|
|
292
|
+
Kiosk-PoW: {"challenge":{…},"nonce":{"indices":[…],"header_nonce":0}}
|
|
293
|
+
|
|
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.
|
|
302
|
+
|
|
303
|
+
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.
|
|
311
|
+
|
|
312
|
+
## Claim ceremony
|
|
313
|
+
|
|
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>".
|
|
321
|
+
They approve in their own signed-in browser session.
|
|
322
|
+
3. Poll `POST #{endpoint}/oauth/token` (form-encoded:
|
|
323
|
+
`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.
|
|
328
|
+
|
|
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 }`.
|
|
333
|
+
|
|
334
|
+
Where registration has a toll, the link-code claim and the FIRST
|
|
335
|
+
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.
|
|
339
|
+
|
|
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).
|
|
343
|
+
|
|
344
|
+
## Exchange
|
|
345
|
+
|
|
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.
|
|
351
|
+
|
|
352
|
+
## Use the access_token
|
|
353
|
+
|
|
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.
|
|
359
|
+
|
|
360
|
+
## Errors
|
|
361
|
+
|
|
362
|
+
- Kiosk endpoints (`/auth/*`, `pay`, every query and every action)
|
|
363
|
+
answer an error as an RFC 9457 problem document, served as
|
|
364
|
+
`application/problem+json`:
|
|
365
|
+
|
|
366
|
+
{"type":"https://kiosk.tech/problems/pow_required",
|
|
367
|
+
"title":"Proof-of-work required","status":402,
|
|
368
|
+
"detail":"proof-of-work required","code":"pow_required"}
|
|
369
|
+
|
|
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`.
|
|
400
|
+
|
|
401
|
+
## Revocation
|
|
402
|
+
|
|
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.
|
|
411
|
+
MARKDOWN
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# How a wire answer is written: headers, cache policy, body. Success is the
|
|
8
|
+
# verb's payload; a refusal is an RFC 9457 problem document.
|
|
9
|
+
module WireRendering
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Shared with {OpenApiController#show}.
|
|
13
|
+
def render_public_document(json, version:, etag:, content_type: nil)
|
|
14
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
15
|
+
Kiosk::Server::Headers.add_public_cache_policy(
|
|
16
|
+
response.headers, etag: etag, immutable: params[:v].to_s == version
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if if_none_match?(etag)
|
|
20
|
+
head :not_modified
|
|
21
|
+
else
|
|
22
|
+
render(**render_options(json, :ok, content_type))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Last: Rails stamps `Vary: Accept` at render time.
|
|
26
|
+
response.headers.delete('Vary')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# RFC 9110 §13.1.2; not `fresh_when`, which hashes the validator it is given.
|
|
30
|
+
def if_none_match?(etag)
|
|
31
|
+
raw = request.get_header('HTTP_IF_NONE_MATCH').to_s
|
|
32
|
+
return false if raw.empty?
|
|
33
|
+
return true if raw.strip == '*'
|
|
34
|
+
|
|
35
|
+
raw.split(',').any? { |tag| tag.strip.delete_prefix('W/') == etag }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_result(result)
|
|
39
|
+
add_pagination_headers(result)
|
|
40
|
+
render_wire_body(result.to_payload, status: result.http_status)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# §8.4: `Link` only on a truncated page; `X-Total-Count` is the array's
|
|
44
|
+
# length on a complete answer, else only what the handler passed.
|
|
45
|
+
def add_pagination_headers(result)
|
|
46
|
+
return unless result.kind == :rows
|
|
47
|
+
|
|
48
|
+
add_link_header(next_page_link(result.next_cursor)) if result.next_cursor
|
|
49
|
+
total = page_total(result)
|
|
50
|
+
response.headers['X-Total-Count'] = total.to_s unless total.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def page_total(result)
|
|
54
|
+
return result.total unless result.total.nil?
|
|
55
|
+
|
|
56
|
+
result.payload.length if result.next_cursor.nil? && result.payload.is_a?(::Array)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_link_header(value)
|
|
60
|
+
existing = response.headers['Link'].to_s
|
|
61
|
+
response.headers['Link'] = existing.empty? ? value : "#{existing}, #{value}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Edits the raw query string, so the caller's bracket spellings survive.
|
|
65
|
+
def next_page_link(cursor)
|
|
66
|
+
pairs = request.query_string.to_s.split('&').reject do |pair|
|
|
67
|
+
pair.split('=', 2).first == 'cursor'
|
|
68
|
+
end
|
|
69
|
+
pairs << "cursor=#{CGI.escape(cursor.to_s)}"
|
|
70
|
+
|
|
71
|
+
%(<#{request.base_url}#{request.path}?#{pairs.join('&')}>; rel="next")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_wire_error(error)
|
|
75
|
+
render_wire_body(
|
|
76
|
+
error.to_problem,
|
|
77
|
+
status: error.http_status,
|
|
78
|
+
error: error,
|
|
79
|
+
content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_wire_body(body, status:, error: nil, content_type: nil)
|
|
84
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
85
|
+
Kiosk::Server::Headers.add_cache_policy(
|
|
86
|
+
response.headers, status: ::Rack::Utils.status_code(status)
|
|
87
|
+
)
|
|
88
|
+
add_error_headers(error) if error
|
|
89
|
+
render(**render_options(body, status, content_type))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_options(body, status, content_type)
|
|
93
|
+
options = { json: body, status: status }
|
|
94
|
+
options[:content_type] = content_type if content_type
|
|
95
|
+
options
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_error_headers(error)
|
|
99
|
+
error.response_headers.each { |name, value| response.set_header(name, value) }
|
|
100
|
+
challenge = www_authenticate_for(error)
|
|
101
|
+
response.set_header('WWW-Authenticate', challenge) if challenge
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# RFC 7235: the header names which 402 gate answered, keyed on the code.
|
|
105
|
+
def www_authenticate_for(error)
|
|
106
|
+
issuer = Kiosk.current_issuer
|
|
107
|
+
case error.code
|
|
108
|
+
when 'pow_required'
|
|
109
|
+
%(Kiosk-PoW realm="#{issuer}")
|
|
110
|
+
when 'payment_setup_required'
|
|
111
|
+
%(Payment realm="#{issuer}", method="ap2")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# The account holder's browser pages. A JSON caller without a CSRF token is
|
|
8
|
+
# an assistant at the wrong door: the answer points it at the wire. Browser
|
|
9
|
+
# requests re-raise untouched. Each page's controller sets WRONG_DOOR_MESSAGE.
|
|
10
|
+
module WrongDoor
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
included do
|
|
14
|
+
rescue_from ::ActionController::InvalidAuthenticityToken do |error|
|
|
15
|
+
raise error unless json_request?
|
|
16
|
+
|
|
17
|
+
render json: wrong_door_envelope, status: :unprocessable_entity
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# An explicit JSON Accept or a JSON body; anything ambiguous counts as a browser.
|
|
24
|
+
def json_request?
|
|
25
|
+
return true if request.format.json?
|
|
26
|
+
|
|
27
|
+
!!request.content_mime_type&.json?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Not a wire problem document or wire error code: this page is not the wire.
|
|
33
|
+
def wrong_door_envelope
|
|
34
|
+
hint = "assistants use the wire: GET #{request.base_url}/.well-known/kiosk.json " \
|
|
35
|
+
'for the register/login endpoints, then GET <endpoint>/schema ' \
|
|
36
|
+
'(public) for the verbs this origin serves'
|
|
37
|
+
{ ok: false,
|
|
38
|
+
error: { code: 'invalid_authenticity_token', message: self.class::WRONG_DOOR_MESSAGE, hint: hint } }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/kiosk/server.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# kiosk-server: the Rails engine, its wire, auth and discovery controllers, and
|
|
4
|
+
# the install generator. See https://kiosk.tech.
|
|
5
|
+
|
|
6
|
+
require 'kiosk'
|
|
7
|
+
require 'active_record'
|
|
8
|
+
require 'action_dispatch/routing'
|
|
9
|
+
require 'active_support/core_ext' # the core extensions a Rails host loads, which the engine's code uses
|
|
10
|
+
require 'zeitwerk'
|
|
11
|
+
|
|
12
|
+
Zeitwerk::Loader.for_gem_extension(Kiosk).setup
|
|
13
|
+
|
|
14
|
+
module Kiosk
|
|
15
|
+
module Server
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The user id and role of the principal the wire resolved.
|
|
19
|
+
def self.current_user_id = Server::SessionContext.identity.user_id
|
|
20
|
+
def self.current_role = Server::SessionContext.identity.role
|
|
21
|
+
|
|
22
|
+
# The zone the caller declared in `Kiosk-Timezone`, else `default`. Never the clock of a service time.
|
|
23
|
+
def self.reader_zone(default = nil) = Server::CurrentRequest.timezone || default
|
|
24
|
+
|
|
25
|
+
# "<name> runs on <zone> time, 2 hours ahead of your <caller_zone>." when the two clocks
|
|
26
|
+
# differ now, else nil. A place adds a second sentence: every time in the answer is the place's.
|
|
27
|
+
def self.clock_gap_note(name, zone, caller_zone)
|
|
28
|
+
return if caller_zone.nil?
|
|
29
|
+
|
|
30
|
+
gap = zone.now.utc_offset - caller_zone.now.utc_offset
|
|
31
|
+
return if gap.zero?
|
|
32
|
+
|
|
33
|
+
hours = format('%g', gap.abs / 3600.0)
|
|
34
|
+
"#{name} runs on #{zone.name} time, #{hours} #{hours == '1' ? 'hour' : 'hours'} " \
|
|
35
|
+
"#{gap.positive? ? 'ahead of' : 'behind'} your #{caller_zone.name}."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Kiosk::Configuration.include(Kiosk::Server::ConfigurationExtension)
|
|
40
|
+
ActionDispatch::Routing::Mapper.include(Kiosk::Server::Routing)
|
|
41
|
+
# Rails registers an engine when its class is defined.
|
|
42
|
+
Kiosk::Server::Engine
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
# `include Kiosk::SkillLink` in a page controller: every answer carries
|
|
7
|
+
# `Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
|
|
8
|
+
# does not read HTML.
|
|
9
|
+
module SkillLink
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
before_action { response.set_header('Link', %(<#{Kiosk.configuration.skill_url}>; rel="kiosk")) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|