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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- 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
|
-
#
|
|
9
|
-
# of one configuration and the request's base URL, so they
|
|
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
|
|
16
|
+
AGENTS_STANDARD = 'https://agents-txt.com'
|
|
17
|
+
AGENTS_SCHEMA = 'https://agents-txt.com/schema/agents-json/v1.0.json'
|
|
15
18
|
|
|
16
|
-
#
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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('',
|
|
54
|
-
lines.push('',
|
|
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
|
|
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
|
-
|
|
66
|
-
doc[:
|
|
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/
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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/
|
|
88
|
-
#
|
|
89
|
-
#
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
{ linkset:
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
#
|
|
151
|
-
def
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
191
|
-
|
|
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
|
|
231
|
+
'is the origin a token or a checkout names'
|
|
203
232
|
end
|
|
204
233
|
end
|
|
205
234
|
|
|
206
|
-
# The api-catalog's
|
|
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
|
-
|
|
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
|
-
|
|
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}",
|
|
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
|
-
|
|
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}
|
|
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:
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
-
|
|
254
|
-
-
|
|
255
|
-
#{
|
|
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
|
-
- **
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
- **
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
- **Link code** — supported
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
295
|
-
`Kiosk-PoW` header line
|
|
296
|
-
request body: a body `pow` field
|
|
297
|
-
|
|
298
|
-
on the wire verbs too
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
305
|
-
mismatch
|
|
306
|
-
failed.
|
|
307
|
-
`#{PowGate::POW_SOLVER_URL}
|
|
308
|
-
That URL
|
|
309
|
-
|
|
310
|
-
above publishes
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 #{
|
|
373
|
+
3. Poll `POST #{urls[:token]}` (form-encoded:
|
|
323
374
|
`grant_type=urn:ietf:params:oauth:grant-type:device_code`,
|
|
324
|
-
`device_code`,
|
|
325
|
-
|
|
326
|
-
|
|
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 (
|
|
330
|
-
|
|
331
|
-
`POST #{
|
|
332
|
-
→ `
|
|
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
|
|
337
|
-
SAME request again with the `Kiosk-PoW` header. Later
|
|
338
|
-
same `device_code`
|
|
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
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
`
|
|
350
|
-
|
|
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:
|
|
355
|
-
under `#{endpoint}`
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
371
|
-
the closed vocabulary
|
|
372
|
-
`pow_required`, …);
|
|
373
|
-
and names the same
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`501 module_not_served`
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`
|
|
394
|
-
`
|
|
395
|
-
`
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|