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
data/lib/kiosk/server.rb
CHANGED
|
@@ -10,6 +10,8 @@ require 'active_support/core_ext' # the core extensions a Rails host loads, whic
|
|
|
10
10
|
require 'zeitwerk'
|
|
11
11
|
|
|
12
12
|
Zeitwerk::Loader.for_gem_extension(Kiosk).setup
|
|
13
|
+
# Registers the JOSE algorithm EdDSA with ruby-jwt.
|
|
14
|
+
require_relative 'server/eddsa'
|
|
13
15
|
|
|
14
16
|
module Kiosk
|
|
15
17
|
module Server
|
data/lib/kiosk/skill_link.rb
CHANGED
|
@@ -5,12 +5,16 @@ require 'active_support/concern'
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
# `include Kiosk::SkillLink` in a page controller: every answer carries
|
|
7
7
|
# `Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
|
|
8
|
-
# does not read HTML.
|
|
8
|
+
# does not read HTML, and the RFC 9727 `api-catalog` link.
|
|
9
9
|
module SkillLink
|
|
10
10
|
extend ActiveSupport::Concern
|
|
11
11
|
|
|
12
|
+
API_CATALOG = '</.well-known/api-catalog>; rel="api-catalog"'
|
|
13
|
+
|
|
12
14
|
included do
|
|
13
|
-
before_action
|
|
15
|
+
before_action do
|
|
16
|
+
response.set_header('Link', %(<#{Kiosk.configuration.skill_url}>; rel="kiosk", #{API_CATALOG}))
|
|
17
|
+
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
data/listen.py
CHANGED
|
@@ -8,10 +8,15 @@ cadence to invent.
|
|
|
8
8
|
|
|
9
9
|
python3 listen.py --url wss://shop.example/kiosk/events \
|
|
10
10
|
--token-file ~/.kiosk/https_shop.example_443/token \
|
|
11
|
+
--proof-command "python3 ~/.kiosk/dpop.py ~/.kiosk/https_shop.example_443/key.pem" \
|
|
11
12
|
--topic order_delivery:<order-id>
|
|
12
13
|
|
|
13
|
-
The
|
|
14
|
-
process listing and shell history would show it.
|
|
14
|
+
The access token is read from a file, never from the command line, where every
|
|
15
|
+
process listing and shell history would show it. The token works only with a
|
|
16
|
+
DPoP proof (RFC 9449) that your private key signs. This file never reads that
|
|
17
|
+
key: before each connection it runs your --proof-command, writes the URL to
|
|
18
|
+
prove and the token to its stdin, one per line, and sends the proof that the
|
|
19
|
+
command prints.
|
|
15
20
|
|
|
16
21
|
WAKE ON THE EVENT: run it as a background process your runtime TRACKS and wakes
|
|
17
22
|
you on when it exits. With `--until-event` it prints the first event and exits 0
|
|
@@ -21,7 +26,7 @@ on the event, tell your human, and start it again with `--since <its id>`. Keep
|
|
|
21
26
|
process the runtime does not track -- nohup, `&`, setsid -- wakes nobody, and
|
|
22
27
|
neither does a line landing in a file.
|
|
23
28
|
|
|
24
|
-
python3 listen.py --url … --token-file … --topic kyc_verification \
|
|
29
|
+
python3 listen.py --url … --token-file … --proof-command … --topic kyc_verification \
|
|
25
30
|
--until-event --max-seconds 300
|
|
26
31
|
|
|
27
32
|
Each line is a JSON object with a `type`:
|
|
@@ -36,7 +41,8 @@ Each line is a JSON object with a `type`:
|
|
|
36
41
|
{"type":"error","message":…}
|
|
37
42
|
|
|
38
43
|
EXIT CODES: 0 you asked it to stop (--max-seconds), or --until-event got its
|
|
39
|
-
event; 2 the arguments or the URL are wrong
|
|
44
|
+
event; 2 the arguments or the URL are wrong, or --proof-command printed no
|
|
45
|
+
proof; 3 the operator will not have this
|
|
40
46
|
token back and no --token-command was given — mint a fresh one and start it
|
|
41
47
|
again; 4 nothing you asked for can arrive: every topic you named was refused,
|
|
42
48
|
or the origin serves no event stream (`501 module_not_served`); 5
|
|
@@ -88,6 +94,22 @@ def problem_code(response):
|
|
|
88
94
|
return None
|
|
89
95
|
|
|
90
96
|
|
|
97
|
+
def proof_url(url):
|
|
98
|
+
"""The `htu` of the upgrade: the events URL with `wss` written as `https`,
|
|
99
|
+
no query."""
|
|
100
|
+
parts = urlsplit(url)
|
|
101
|
+
scheme = {"wss": "https", "ws": "http"}[parts.scheme]
|
|
102
|
+
return urlunsplit((scheme, parts.netloc, parts.path, "", ""))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def dpop_proof(command, url, token):
|
|
106
|
+
"""The proof your --proof-command prints for this URL and token, or None."""
|
|
107
|
+
made = subprocess.run(command, shell=True, input=f"{proof_url(url)}\n{token}\n",
|
|
108
|
+
capture_output=True, text=True, check=False)
|
|
109
|
+
proof = made.stdout.strip()
|
|
110
|
+
return proof if made.returncode == 0 and proof else None
|
|
111
|
+
|
|
112
|
+
|
|
91
113
|
def stream_url(url, topics, since):
|
|
92
114
|
"""`?topic=` is how you subscribe: the operator reads it on connect and
|
|
93
115
|
confirms each one, so a round trip per topic is not needed."""
|
|
@@ -145,7 +167,7 @@ def read_frames(socket, state, deadline):
|
|
|
145
167
|
reconnect=frame.get("reconnect", True))
|
|
146
168
|
# `reconnect: false` is the operator saying this credential is done:
|
|
147
169
|
# it expired, or the human revoked the assistant. A retry with the
|
|
148
|
-
# same
|
|
170
|
+
# same token loses the same argument, so mint a new one or stop.
|
|
149
171
|
if frame.get("reconnect", True):
|
|
150
172
|
return "retry"
|
|
151
173
|
return "retry" if state["refresh"] else "credential"
|
|
@@ -191,12 +213,15 @@ def main():
|
|
|
191
213
|
parser = argparse.ArgumentParser(description="Kiosk event-stream listener")
|
|
192
214
|
parser.add_argument("--url", required=True, help="the operator's events_url")
|
|
193
215
|
parser.add_argument("--token-file", required=True, metavar="PATH",
|
|
194
|
-
help="a file holding your
|
|
216
|
+
help="a file holding your access token")
|
|
217
|
+
parser.add_argument("--proof-command", required=True,
|
|
218
|
+
help="shell command that reads a URL and a token on stdin and prints "
|
|
219
|
+
"the DPoP proof your private key signs for them")
|
|
195
220
|
parser.add_argument("--topic", action="append", required=True, metavar="NAME[:SUBJECT]",
|
|
196
221
|
help="repeatable; NAME:SUBJECT narrows to one row")
|
|
197
222
|
parser.add_argument("--since", type=int, help="resume after this event id")
|
|
198
223
|
parser.add_argument("--token-command",
|
|
199
|
-
help="shell command printing a fresh
|
|
224
|
+
help="shell command printing a fresh access token; run when the "
|
|
200
225
|
"operator says this one will not be accepted back")
|
|
201
226
|
parser.add_argument("--max-seconds", type=float, help="stop after this long")
|
|
202
227
|
parser.add_argument("--until-event", action="store_true",
|
|
@@ -224,10 +249,15 @@ def main():
|
|
|
224
249
|
if deadline and time.monotonic() >= deadline:
|
|
225
250
|
return 5 if args.until_event else 0
|
|
226
251
|
renew = True
|
|
252
|
+
proof = dpop_proof(args.proof_command, args.url, state["token"])
|
|
253
|
+
if proof is None:
|
|
254
|
+
emit(type="error", message="--proof-command produced no proof")
|
|
255
|
+
return 2
|
|
227
256
|
try:
|
|
228
257
|
with connect(stream_url(args.url, args.topic, state["since"]),
|
|
229
258
|
subprotocols=[SUBPROTOCOL],
|
|
230
|
-
additional_headers={"Authorization": f"
|
|
259
|
+
additional_headers={"Authorization": f"DPoP {state['token']}",
|
|
260
|
+
"DPoP": proof},
|
|
231
261
|
open_timeout=15, close_timeout=5) as socket:
|
|
232
262
|
emit(type="open")
|
|
233
263
|
attempt = 0
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kiosk-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Phil Pirozhkov
|
|
@@ -15,21 +15,21 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.6.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.6.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: jwt
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '2.
|
|
32
|
+
version: '2.10'
|
|
33
33
|
- - "<"
|
|
34
34
|
- !ruby/object:Gem::Version
|
|
35
35
|
version: '4.0'
|
|
@@ -39,7 +39,7 @@ dependencies:
|
|
|
39
39
|
requirements:
|
|
40
40
|
- - ">="
|
|
41
41
|
- !ruby/object:Gem::Version
|
|
42
|
-
version: '2.
|
|
42
|
+
version: '2.10'
|
|
43
43
|
- - "<"
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '4.0'
|
|
@@ -167,14 +167,15 @@ description: |
|
|
|
167
167
|
- The controllers — the per-verb wire (GET <endpoint>/<query-name>,
|
|
168
168
|
POST <endpoint>/<action-name>) and the reserved endpoints
|
|
169
169
|
(/kiosk/schema, /kiosk/pay), a derived OpenAPI description of both,
|
|
170
|
-
|
|
170
|
+
OAuth client registration (RFC 7591) and the token endpoint
|
|
171
|
+
(private_key_jwt client assertions, DPoP), JWKS, the KYC
|
|
171
172
|
attestation endpoint, agents.txt / agents.json / kiosk.json
|
|
172
|
-
discovery, and the account-binding ceremony (RFC 8628
|
|
173
|
-
authorization
|
|
174
|
-
|
|
173
|
+
discovery, and the account-binding ceremony (RFC 8628 device
|
|
174
|
+
authorization and token poll, the verify and «Link an assistant»
|
|
175
|
+
pages).
|
|
175
176
|
- Kiosk::Server::Executor — dispatches a resolved command to the
|
|
176
177
|
queries and actions the host registered.
|
|
177
|
-
-
|
|
178
|
+
- Client registration and authentication, with a pluggable agent-IdP.
|
|
178
179
|
- The PoW gate that enforces a kiosk-reputation policy's challenge
|
|
179
180
|
(soft dependency; zero overhead when no policy is set).
|
|
180
181
|
- A Rack middleware injecting the Kiosk-Server-Version /
|
|
@@ -208,12 +209,13 @@ files:
|
|
|
208
209
|
- app/controllers/kiosk/server/kyc_attestation_controller.rb
|
|
209
210
|
- app/controllers/kiosk/server/kyc_callback_controller.rb
|
|
210
211
|
- app/controllers/kiosk/server/oauth_device_authorization_controller.rb
|
|
212
|
+
- app/controllers/kiosk/server/oauth_registration_controller.rb
|
|
211
213
|
- app/controllers/kiosk/server/oauth_token_controller.rb
|
|
212
214
|
- app/controllers/kiosk/server/open_api_controller.rb
|
|
213
215
|
- app/controllers/kiosk/server/payment_setup_controller.rb
|
|
214
216
|
- app/controllers/kiosk/server/verb_controller.rb
|
|
215
217
|
- app/controllers/kiosk/server/wire_controller.rb
|
|
216
|
-
- app/models/kiosk/
|
|
218
|
+
- app/models/kiosk/checkout_mandate.rb
|
|
217
219
|
- app/models/kiosk/server/application_record.rb
|
|
218
220
|
- app/models/kiosk/settlement.rb
|
|
219
221
|
- app/models/kiosk/visit.rb
|
|
@@ -221,7 +223,6 @@ files:
|
|
|
221
223
|
- app/views/kiosk/server/device_verify/decided.html.erb
|
|
222
224
|
- app/views/kiosk/server/device_verify/show.html.erb
|
|
223
225
|
- lib/generators/kiosk/install/install_generator.rb
|
|
224
|
-
- lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt
|
|
225
226
|
- lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt
|
|
226
227
|
- lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt
|
|
227
228
|
- lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt
|
|
@@ -242,15 +243,17 @@ files:
|
|
|
242
243
|
- lib/kiosk/server/action_event.rb
|
|
243
244
|
- lib/kiosk/server/actions.rb
|
|
244
245
|
- lib/kiosk/server/agent_identity_providers/default_agent_idp.rb
|
|
245
|
-
- lib/kiosk/server/
|
|
246
|
-
- lib/kiosk/server/agent_registration.rb
|
|
246
|
+
- lib/kiosk/server/agent_rotation.rb
|
|
247
247
|
- lib/kiosk/server/argument_decoder.rb
|
|
248
|
+
- lib/kiosk/server/assistant_key.rb
|
|
248
249
|
- lib/kiosk/server/audit_sink.rb
|
|
249
|
-
- lib/kiosk/server/auth_challenge.rb
|
|
250
|
-
- lib/kiosk/server/auth_challenge_stores.rb
|
|
251
250
|
- lib/kiosk/server/auth_toll.rb
|
|
252
251
|
- lib/kiosk/server/binding_module_gate.rb
|
|
253
252
|
- lib/kiosk/server/caller_timezone.rb
|
|
253
|
+
- lib/kiosk/server/checkout.rb
|
|
254
|
+
- lib/kiosk/server/client.rb
|
|
255
|
+
- lib/kiosk/server/client_assertion.rb
|
|
256
|
+
- lib/kiosk/server/client_registration.rb
|
|
254
257
|
- lib/kiosk/server/column_spending_cap.rb
|
|
255
258
|
- lib/kiosk/server/configuration_extension.rb
|
|
256
259
|
- lib/kiosk/server/controller_verb.rb
|
|
@@ -260,6 +263,8 @@ files:
|
|
|
260
263
|
- lib/kiosk/server/device_authorization_stores.rb
|
|
261
264
|
- lib/kiosk/server/device_code_grant.rb
|
|
262
265
|
- lib/kiosk/server/device_verification.rb
|
|
266
|
+
- lib/kiosk/server/dpop.rb
|
|
267
|
+
- lib/kiosk/server/eddsa.rb
|
|
263
268
|
- lib/kiosk/server/engine.rb
|
|
264
269
|
- lib/kiosk/server/errors.rb
|
|
265
270
|
- lib/kiosk/server/event_stores.rb
|
|
@@ -277,6 +282,7 @@ files:
|
|
|
277
282
|
- lib/kiosk/server/headers_middleware.rb
|
|
278
283
|
- lib/kiosk/server/identity_resolution.rb
|
|
279
284
|
- lib/kiosk/server/issuer_middleware.rb
|
|
285
|
+
- lib/kiosk/server/jwk.rb
|
|
280
286
|
- lib/kiosk/server/jwks.rb
|
|
281
287
|
- lib/kiosk/server/jwt_issuer.rb
|
|
282
288
|
- lib/kiosk/server/kyc.rb
|
|
@@ -284,15 +290,17 @@ files:
|
|
|
284
290
|
- lib/kiosk/server/link_code.rb
|
|
285
291
|
- lib/kiosk/server/linked_assistants.rb
|
|
286
292
|
- lib/kiosk/server/mandate_verifier.rb
|
|
293
|
+
- lib/kiosk/server/oauth_answers.rb
|
|
294
|
+
- lib/kiosk/server/oauth_error.rb
|
|
287
295
|
- lib/kiosk/server/open_api.rb
|
|
288
296
|
- lib/kiosk/server/page.rb
|
|
289
297
|
- lib/kiosk/server/payment_claim.rb
|
|
290
298
|
- lib/kiosk/server/payment_setup.rb
|
|
291
|
-
- lib/kiosk/server/pop_verifier.rb
|
|
292
299
|
- lib/kiosk/server/pow_gate.rb
|
|
293
300
|
- lib/kiosk/server/pow_spent_stores.rb
|
|
294
301
|
- lib/kiosk/server/pow_toll.rb
|
|
295
302
|
- lib/kiosk/server/queries.rb
|
|
303
|
+
- lib/kiosk/server/receipts.rb
|
|
296
304
|
- lib/kiosk/server/reputation_factors.rb
|
|
297
305
|
- lib/kiosk/server/request_validation.rb
|
|
298
306
|
- lib/kiosk/server/response_validation.rb
|
|
@@ -307,6 +315,7 @@ files:
|
|
|
307
315
|
- lib/kiosk/server/schemas/kyc.schema.json
|
|
308
316
|
- lib/kiosk/server/schemas/mandates.schema.json
|
|
309
317
|
- lib/kiosk/server/schemas/pow.schema.json
|
|
318
|
+
- lib/kiosk/server/sd_jwt.rb
|
|
310
319
|
- lib/kiosk/server/session_context.rb
|
|
311
320
|
- lib/kiosk/server/settled_spend.rb
|
|
312
321
|
- lib/kiosk/server/signing_key.rb
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
# The signed cart the engine records when it settles a `pay`: the line items
|
|
5
|
-
# the assistant agreed to pay for. Read-only for the operator.
|
|
6
|
-
class CartMandate < Server::ApplicationRecord
|
|
7
|
-
def self.table_name = "#{Kiosk.configuration.schema}.cart_mandates"
|
|
8
|
-
|
|
9
|
-
has_many :settlements, class_name: 'Kiosk::Settlement', dependent: nil, inverse_of: :cart_mandate
|
|
10
|
-
|
|
11
|
-
# Carts with a line item carrying these attributes, by jsonb containment:
|
|
12
|
-
# `referencing(order_id: id)` matches `[{"order_id": id, "qty": 2}]`. The
|
|
13
|
-
# values are quoted by the adapter, never interpolated.
|
|
14
|
-
scope :referencing, lambda { |**attributes|
|
|
15
|
-
where(Arel::Nodes::InfixOperation.new(
|
|
16
|
-
'@>', arel_table[:line_items],
|
|
17
|
-
Arel::Nodes.build_quoted([attributes.transform_values(&:to_s)].to_json)
|
|
18
|
-
))
|
|
19
|
-
}
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Migration 010 — create kiosk.auth_challenges.
|
|
4
|
-
# Each public key's outstanding auth challenge, so a challenge issued by one
|
|
5
|
-
# process is redeemed by any other (AuthChallengeStores::ActiveRecord, the default).
|
|
6
|
-
class CreateKioskAuthChallenges < ActiveRecord::Migration[ActiveRecord::Migration.current_version]
|
|
7
|
-
def up
|
|
8
|
-
execute <<~'SQL'
|
|
9
|
-
<%= ddl(:auth_challenge) %>
|
|
10
|
-
SQL
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def down
|
|
14
|
-
execute %(DROP TABLE IF EXISTS "<%= options[:schema] %>".auth_challenges)
|
|
15
|
-
end
|
|
16
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
module Server
|
|
5
|
-
# Login with an already-registered public key: proves possession and mints a
|
|
6
|
-
# fresh token. An unknown key is a 404, never a new account.
|
|
7
|
-
module AgentLogin
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
def call(public_key_pem:, signed:)
|
|
11
|
-
pem = public_key_pem.to_s.strip
|
|
12
|
-
|
|
13
|
-
# Prove possession BEFORE any lookup or state change.
|
|
14
|
-
payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
|
|
15
|
-
AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
|
|
16
|
-
|
|
17
|
-
row = registered_agent(pem)
|
|
18
|
-
agent_id = row.fetch('id')
|
|
19
|
-
role = primary_role(row.fetch('allowed_roles'))
|
|
20
|
-
token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
|
|
21
|
-
{ access_token: token }
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def registered_agent(pem)
|
|
25
|
-
row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
|
|
26
|
-
SELECT id, user_id, allowed_roles FROM #{Kiosk.configuration.schema}.agents
|
|
27
|
-
WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
28
|
-
LIMIT 1
|
|
29
|
-
SQL
|
|
30
|
-
return row if row
|
|
31
|
-
|
|
32
|
-
raise Errors::NotFound.new(
|
|
33
|
-
'no agent registered for this public key',
|
|
34
|
-
hint: 'POST /auth/register to create an identity for a new key'
|
|
35
|
-
)
|
|
36
|
-
end
|
|
37
|
-
private_class_method :registered_agent
|
|
38
|
-
|
|
39
|
-
# `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
|
|
40
|
-
def primary_role(allowed_roles)
|
|
41
|
-
case allowed_roles
|
|
42
|
-
when Array then allowed_roles.first
|
|
43
|
-
else allowed_roles.to_s.delete('{}').split(',').first
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
private_class_method :primary_role
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'active_record'
|
|
4
|
-
require 'active_support/core_ext/string/inflections' # String#constantize
|
|
5
|
-
|
|
6
|
-
module Kiosk
|
|
7
|
-
module Server
|
|
8
|
-
# Self-registration with no human: creates the assistant account, binds an
|
|
9
|
-
# agent to the key, and mints its first token.
|
|
10
|
-
module AgentRegistration
|
|
11
|
-
module_function
|
|
12
|
-
|
|
13
|
-
def call(public_key_pem:, signed:, pow: nil)
|
|
14
|
-
config = Kiosk.configuration
|
|
15
|
-
role = registration_role(config)
|
|
16
|
-
public_key_pem = public_key_pem.to_s.strip
|
|
17
|
-
|
|
18
|
-
AuthToll.gate(verb: 'auth/register', body: { public_key: public_key_pem }, pow: pow, config: config)
|
|
19
|
-
|
|
20
|
-
# Prove possession of the private key before any lookup or write.
|
|
21
|
-
payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
|
|
22
|
-
AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
|
|
23
|
-
|
|
24
|
-
conn = ::ActiveRecord::Base.lease_connection
|
|
25
|
-
refuse_known_key!(conn, config, public_key_pem)
|
|
26
|
-
conn.transaction { register(conn, config, public_key_pem, role) }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# The operator's `assistant_creation` factory, else a bare `user_model.create!`.
|
|
30
|
-
def create_assistant_account(config, public_key_pem)
|
|
31
|
-
return config.user_model.constantize.create!.id unless config.assistant_creation
|
|
32
|
-
|
|
33
|
-
id = config.assistant_creation.call(public_key_pem)
|
|
34
|
-
return id unless id.nil?
|
|
35
|
-
|
|
36
|
-
raise Errors::ConfigurationError,
|
|
37
|
-
'config.assistant_creation returned nil. The block must create the ' \
|
|
38
|
-
'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
|
|
39
|
-
'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# The role is the server's choice, never the agent's.
|
|
43
|
-
def registration_role(config)
|
|
44
|
-
role = config.registration_role&.to_s
|
|
45
|
-
return if role.blank?
|
|
46
|
-
return role if config.roles.map(&:to_s).include?(role)
|
|
47
|
-
|
|
48
|
-
raise Errors::ConfigurationError,
|
|
49
|
-
"registration_role #{role.inspect} is not among configured roles " \
|
|
50
|
-
"#{config.roles.inspect}"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# A known key logs in instead; it is never registered twice.
|
|
54
|
-
def refuse_known_key!(conn, config, public_key_pem)
|
|
55
|
-
existing = conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [public_key_pem, Kiosk.current_issuer]).to_a.first
|
|
56
|
-
SELECT id FROM #{config.schema}.agents
|
|
57
|
-
WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
58
|
-
LIMIT 1
|
|
59
|
-
SQL
|
|
60
|
-
return unless existing
|
|
61
|
-
|
|
62
|
-
raise Errors::Conflict.new(
|
|
63
|
-
'public key already registered',
|
|
64
|
-
hint: 'use POST /auth/login to refresh a token for an existing key'
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def register(conn, config, public_key_pem, role)
|
|
69
|
-
assistant_account_id = create_assistant_account(config, public_key_pem)
|
|
70
|
-
agent_id = insert_agent(conn, config, [assistant_account_id, public_key_pem, Kiosk.current_issuer], role)
|
|
71
|
-
token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
|
|
72
|
-
{ agent_id: agent_id, user_id: assistant_account_id.to_s, access_token: token }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# No role is the empty array: `allowed_roles` is NOT NULL.
|
|
76
|
-
def insert_agent(conn, config, binds, role)
|
|
77
|
-
allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
|
|
78
|
-
rows = conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, *role_binds])
|
|
79
|
-
INSERT INTO #{config.schema}.agents (user_id, allowed_roles, public_key, issuer)
|
|
80
|
-
VALUES ($1, #{allowed_roles_sql}, $2, $3)
|
|
81
|
-
RETURNING id
|
|
82
|
-
SQL
|
|
83
|
-
rows.to_a.first.fetch('id')
|
|
84
|
-
end
|
|
85
|
-
private_class_method :registration_role, :refuse_known_key!, :register, :insert_agent
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'securerandom'
|
|
4
|
-
|
|
5
|
-
module Kiosk
|
|
6
|
-
module Server
|
|
7
|
-
# Server side of the proof-of-possession challenge: a single-use, short-lived
|
|
8
|
-
# nonce per public key.
|
|
9
|
-
module AuthChallenge
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
# The agent signs `challenge` into its JWS; `exp` is the Unix expiry.
|
|
13
|
-
def issue(public_key_pem:, now: Time.current)
|
|
14
|
-
config = Kiosk.configuration
|
|
15
|
-
nonce = SecureRandom.urlsafe_base64(32)
|
|
16
|
-
exp = now.to_i + config.auth_challenge_ttl
|
|
17
|
-
config.auth_challenge_store.put(public_key_pem.to_s.strip, nonce, exp)
|
|
18
|
-
{ challenge: nonce, exp: exp }
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def consume!(public_key_pem:, nonce:)
|
|
22
|
-
ok = Kiosk.configuration.auth_challenge_store.take(public_key_pem.to_s.strip, nonce.to_s)
|
|
23
|
-
return true if ok
|
|
24
|
-
|
|
25
|
-
raise Errors::Unauthenticated.new(
|
|
26
|
-
'no matching auth challenge',
|
|
27
|
-
hint: 'GET /auth/challenge?public_key=… first, then sign the returned nonce; ' \
|
|
28
|
-
'challenges are single-use and short-lived'
|
|
29
|
-
)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Kiosk
|
|
4
|
-
module Server
|
|
5
|
-
# The challenge store, shared by every process on the database (§15.2).
|
|
6
|
-
module AuthChallengeStores
|
|
7
|
-
# The default: the `<schema>.auth_challenges` table ({SchemaDefinitions.auth_challenge_sql}).
|
|
8
|
-
class ActiveRecord
|
|
9
|
-
# Seconds between expired-row sweeps; a sweep bounds growth, not correctness.
|
|
10
|
-
DEFAULT_PRUNE_INTERVAL = 60
|
|
11
|
-
|
|
12
|
-
def initialize(prune_interval: DEFAULT_PRUNE_INTERVAL)
|
|
13
|
-
@prune_interval = prune_interval
|
|
14
|
-
@last_prune_at = 0
|
|
15
|
-
@mutex = Mutex.new
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Replaces any earlier challenge for the key.
|
|
19
|
-
def put(public_key_pem, nonce, exp)
|
|
20
|
-
return if public_key_pem.nil?
|
|
21
|
-
|
|
22
|
-
prune_if_due!
|
|
23
|
-
sql = <<~SQL.squish
|
|
24
|
-
INSERT INTO #{table} AS c (public_key, nonce, expires_at)
|
|
25
|
-
VALUES ($1, $2, to_timestamp($3))
|
|
26
|
-
ON CONFLICT (public_key) DO UPDATE
|
|
27
|
-
SET nonce = EXCLUDED.nonce, expires_at = EXCLUDED.expires_at
|
|
28
|
-
SQL
|
|
29
|
-
connection.exec_query(sql, 'Kiosk auth_challenge put', [public_key_pem, nonce, exp.to_i])
|
|
30
|
-
nil
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# One atomic DELETE, so two processes presenting one challenge get one `true`.
|
|
34
|
-
def take(public_key_pem, nonce) # rubocop:disable Naming/PredicateMethod -- `take` is the store contract `config.auth_challenge_store` names
|
|
35
|
-
return false if public_key_pem.nil? || nonce.nil?
|
|
36
|
-
|
|
37
|
-
sql = <<~SQL.squish
|
|
38
|
-
DELETE FROM #{table}
|
|
39
|
-
WHERE public_key = $1 AND nonce = $2 AND expires_at > now()
|
|
40
|
-
RETURNING public_key
|
|
41
|
-
SQL
|
|
42
|
-
rows = connection.exec_query(
|
|
43
|
-
sql, 'Kiosk auth_challenge take', [public_key_pem, nonce]
|
|
44
|
-
).to_a
|
|
45
|
-
!rows.empty?
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def prune!
|
|
49
|
-
connection.exec_query(%(DELETE FROM #{table} WHERE expires_at <= now()),
|
|
50
|
-
'Kiosk auth_challenge prune')
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def prune_if_due!
|
|
57
|
-
now = Time.now.to_i
|
|
58
|
-
due = @mutex.synchronize do
|
|
59
|
-
if now - @last_prune_at >= @prune_interval
|
|
60
|
-
@last_prune_at = now
|
|
61
|
-
true
|
|
62
|
-
else
|
|
63
|
-
false
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
prune! if due
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def connection = ::ActiveRecord::Base.lease_connection
|
|
70
|
-
def table = %("#{Kiosk.configuration.schema}".auth_challenges)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'jwt'
|
|
4
|
-
require 'openssl'
|
|
5
|
-
require 'rails'
|
|
6
|
-
|
|
7
|
-
module Kiosk
|
|
8
|
-
module Server
|
|
9
|
-
# Verifies an agent's proof-of-possession JWS: RS256 by the presented key,
|
|
10
|
-
# `aud` equal to this origin (the relay defence), and the challenge `nonce`.
|
|
11
|
-
# The caller burns the nonce only after a clean verify.
|
|
12
|
-
module PopVerifier
|
|
13
|
-
# Names no origin: an echoed one is what a relaying server would exploit.
|
|
14
|
-
AUDIENCE_HINT =
|
|
15
|
-
'sign `aud` = the origin you connected to, taken from your own request ' \
|
|
16
|
-
'URL — never from a value echoed back in a response'
|
|
17
|
-
|
|
18
|
-
PROOF_REQUIRED_CLAIMS = %w[aud nonce jti].freeze
|
|
19
|
-
|
|
20
|
-
MAX_KEY_BITS = 4096
|
|
21
|
-
|
|
22
|
-
PROOF_CLAIMS_HINT =
|
|
23
|
-
"a proof's payload carries #{PROOF_REQUIRED_CLAIMS.join(', ')} — `aud` is the origin " \
|
|
24
|
-
'you connected to, `nonce` the value GET /auth/challenge just issued for this key, ' \
|
|
25
|
-
'`jti` a unique id for this proof'.freeze
|
|
26
|
-
|
|
27
|
-
PROOF_SIGNATURE_HINT =
|
|
28
|
-
'a proof is a compact RS256 JWS — header.payload.signature — signed with the ' \
|
|
29
|
-
'private key matching the public_key you sent'
|
|
30
|
-
|
|
31
|
-
module_function
|
|
32
|
-
|
|
33
|
-
def verify!(public_key_pem:, signed:)
|
|
34
|
-
pem = public_key_pem.to_s.strip
|
|
35
|
-
payload = decode(signed, load_public_key(pem))
|
|
36
|
-
check_audience!(payload[:aud])
|
|
37
|
-
raise Errors::Unauthenticated, 'proof key thumbprint mismatch' if payload.key?(:pub) && payload[:pub] != SigningKey.from_pem(pem).kid
|
|
38
|
-
|
|
39
|
-
payload
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def decode(signed, key)
|
|
43
|
-
payload, = ::JWT.decode(signed.to_s, key, true, algorithms: ['RS256'], required_claims: PROOF_REQUIRED_CLAIMS)
|
|
44
|
-
payload.transform_keys(&:to_sym)
|
|
45
|
-
rescue ::JWT::MissingRequiredClaim
|
|
46
|
-
raise Errors::Unauthenticated.new('proof is missing a required claim', hint: PROOF_CLAIMS_HINT)
|
|
47
|
-
rescue ::JWT::DecodeError
|
|
48
|
-
raise Errors::Unauthenticated.new('proof signature invalid', hint: PROOF_SIGNATURE_HINT)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def check_audience!(signed_aud)
|
|
52
|
-
issuer = Kiosk.current_issuer
|
|
53
|
-
return if signed_aud == issuer
|
|
54
|
-
|
|
55
|
-
log_audience_mismatch(signed_aud: signed_aud, issuer: issuer)
|
|
56
|
-
raise Errors::Unauthenticated.new('proof audience mismatch', hint: AUDIENCE_HINT)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Operator log only, since the response must not name an origin: the
|
|
60
|
-
# caller signed a wrong value, or the origin it reached is not served.
|
|
61
|
-
def log_audience_mismatch(signed_aud:, issuer:)
|
|
62
|
-
message = "[kiosk] PoP audience mismatch: caller signed aud=#{signed_aud.inspect}, " \
|
|
63
|
-
"the issuer for this request is #{issuer.inspect}. If the signed value is " \
|
|
64
|
-
'an origin your assistants actually reach, list it in `c.issuer` or ' \
|
|
65
|
-
'`c.additional_origins`.'
|
|
66
|
-
logger = ::Rails.logger
|
|
67
|
-
logger ? logger.warn(message) : warn(message)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Also the key bounds of account binding.
|
|
71
|
-
def load_public_key(pem)
|
|
72
|
-
OpenSSL::PKey::RSA.new(pem).tap { require_key_size!(it.n.num_bits) }
|
|
73
|
-
rescue OpenSSL::PKey::PKeyError
|
|
74
|
-
raise Errors::BadRequest.new(
|
|
75
|
-
'invalid public key',
|
|
76
|
-
hint: "send a PEM-encoded RSA public key of #{SigningKey::MIN_KEY_BITS} to #{MAX_KEY_BITS} bits " \
|
|
77
|
-
'(-----BEGIN PUBLIC KEY----- …)'
|
|
78
|
-
)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def require_key_size!(bits)
|
|
82
|
-
raise Errors::BadRequest, "public key too small (#{bits} bits; minimum #{SigningKey::MIN_KEY_BITS})" if bits < SigningKey::MIN_KEY_BITS
|
|
83
|
-
raise Errors::BadRequest, "public key too large (#{bits} bits; maximum #{MAX_KEY_BITS})" if bits > MAX_KEY_BITS
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|