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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41dc3967e20c6fba6efd8455153f50d9f879aff9b5fac76b1bde712b130358d2
|
|
4
|
+
data.tar.gz: ba6c5c130d8de8fe1cfa438433022beda0253b52880dec4b4f951bcbf039ba96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf66db86ad09c1b90c6f6681224b0497457956e1550fb49a2ed7e685b610dc42398ef7a3be4c47ee4f7bf17bb95448a5561a29ef27a762eed9d2d83c9bf042a8
|
|
7
|
+
data.tar.gz: 6f4047750236cbe4a89b77f7325439943addb71b61efb146d854e25d2a2458ba300a05388146b3a6cdbb30ecb331736797761adb1ad79698d8c824f04f865752
|
data/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,64 @@ already written is edited.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.6.1] — 2026-10-22
|
|
16
|
+
|
|
17
|
+
- 2026-10-22: Version 0.6.1, the tree cut that matches skill 0.6.1.
|
|
18
|
+
- 2026-10-22: The OAuth metadata documents carry `scopes_supported: []`, because an AI assistant requests no scope. `/auth.md` writes each Discover URL bare, so a scanner reads it.
|
|
19
|
+
- 2026-10-21: A DPoP proof or client assertion whose `alg` is not the algorithm of its key is refused with a message that names that algorithm.
|
|
20
|
+
|
|
21
|
+
## [0.6.0] — 2026-10-21
|
|
22
|
+
|
|
23
|
+
- 2026-10-21: The engine signs tokens, checkouts and receipts EdDSA with an Ed25519 `c.signing_key`; an RSA key is refused.
|
|
24
|
+
- 2026-10-21: Assistant keys are Ed25519 (EdDSA). `c.assistant_algorithms = %w[EdDSA ES256]` also accepts P-256 keys; RSA keys are refused.
|
|
25
|
+
- 2026-10-21: A checkout carries a random 128-bit `jti`, because an EdDSA signature is the same for the same claims.
|
|
26
|
+
- 2026-10-21: `c.kyc_public_key` is an Ed25519 key, and an attestation is an EdDSA JWS.
|
|
27
|
+
- 2026-10-21: Version 0.6.0, the tree cut that matches skill 0.6.0.
|
|
28
|
+
|
|
29
|
+
## [0.5.27] — 2026-10-21
|
|
30
|
+
|
|
31
|
+
- 2026-10-21: Registration is RFC 7591 at `POST <endpoint>/oauth/register`; the token endpoint is core and gives tokens for a client assertion (RFC 7523) and a DPoP proof.
|
|
32
|
+
- 2026-10-21: Device authorization, the link-code claim and the key rotation authenticate with a client assertion; the claim answers 204, the rotation the client information.
|
|
33
|
+
- 2026-10-21: Removed `GET /auth/challenge`, `POST /auth/register`, `/auth/login` and `/auth/revoke`, the challenge store, its two settings and the `kiosk.auth_challenges` table.
|
|
34
|
+
- 2026-10-21: agents.txt and agents.json name `oauth2`; the RFC 8414 metadata is always served, with registration and `private_key_jwt`.
|
|
35
|
+
- 2026-10-21: `AssistantKey::ALGORITHMS` is the one list of signing algorithms that client assertions, DPoP proofs and the metadata read.
|
|
36
|
+
- 2026-10-21: Version 0.5.27, the tree cut that matches skill 0.5.27.
|
|
37
|
+
|
|
38
|
+
## [0.5.26] — 2026-10-21
|
|
39
|
+
|
|
40
|
+
- 2026-10-21: Serves RFC 9728 protected resource metadata at `/.well-known/oauth-protected-resource`, and the ARD manifest also at `/.well-known/ai-catalog.json` with `specVersion`.
|
|
41
|
+
- 2026-10-21: Version 0.5.26, the tree cut that matches skill 0.5.26.
|
|
42
|
+
|
|
43
|
+
## [0.5.25] — 2026-10-21
|
|
44
|
+
|
|
45
|
+
- 2026-10-21: Version 0.5.25, the tree cut that matches skill 0.5.25.
|
|
46
|
+
|
|
47
|
+
## [0.5.24] — 2026-10-21
|
|
48
|
+
|
|
49
|
+
- 2026-10-21: A settled `pay` answers the AP2 v0.2 `checkout_receipt` and `payment_receipt`, JWTs signed PS256 with `c.signing_key`, each naming its mandate.
|
|
50
|
+
- 2026-10-21: `kiosk.settlements` gains `network_confirmation_id`, so a repeat of a settled pair answers the same payment receipt claims.
|
|
51
|
+
- 2026-10-21: Version 0.5.24, the tree cut that matches skill 0.5.24.
|
|
52
|
+
|
|
53
|
+
## [0.5.23] — 2026-10-21
|
|
54
|
+
|
|
55
|
+
- 2026-10-21: Every access token carries `cnf.jkt`, and `IdentityResolution` refuses a call without a DPoP proof by that key (`Errors::AccessTokenRefused`, `WWW-Authenticate: DPoP`).
|
|
56
|
+
- 2026-10-21: `POST <endpoint>/auth/rotate` (`AgentRotation`) puts a new key on an agent and revokes its earlier tokens.
|
|
57
|
+
- 2026-10-21: `c.previous_signing_key` is published in the JWKS and accepted for tokens and checkouts; `c.signing_keys` lists both.
|
|
58
|
+
- 2026-10-21: The payment-setup `402` names `Kiosk-Payment-Setup` in `WWW-Authenticate`. The device grant answers `token_type: "DPoP"`.
|
|
59
|
+
- 2026-10-21: Version 0.5.23, the tree cut that matches skill 0.5.23.
|
|
60
|
+
|
|
61
|
+
## [0.5.22] — 2026-10-21
|
|
62
|
+
|
|
63
|
+
- 2026-10-21: `pay` verifies a closed AP2 v0.2 checkout mandate and payment mandate, each an SD-JWT, against a checkout this origin signed PS256.
|
|
64
|
+
- 2026-10-21: `Kiosk::Payable#checkout` signs the `checkout_jwt` an action answers; `c.checkout_price_checker` replaces `c.cart_price_checker`.
|
|
65
|
+
- 2026-10-21: The mandate tables are `checkout_mandates`, `payment_mandates` and `settlements`, with one live pair per checkout. The JWK names no `alg`.
|
|
66
|
+
- 2026-10-21: Version 0.5.22, the tree cut that matches skill 0.5.22.
|
|
67
|
+
|
|
68
|
+
## [0.5.21] — 2026-10-21
|
|
69
|
+
|
|
70
|
+
- agents.txt and agents.json name `x-kiosk-pop` and claim no AP2, required payment or required identity. `/.well-known/agent-configuration` is not served.
|
|
71
|
+
- New: `/.well-known/oauth-authorization-server` (RFC 8414), `/.well-known/agent-skills/index.json` and `/.well-known/ard.json`.
|
|
72
|
+
- The api-catalog links the descriptions from the endpoint and carries `rel="api-catalog"`; `Kiosk::SkillLink` adds that link to a page.
|
|
15
73
|
- `Kiosk.clock_gap_note(name, zone, caller_zone)` answers one sentence on how far a place's clock is from the caller's now, else `nil`.
|
|
16
74
|
|
|
17
75
|
## [0.5.20] — 2026-10-20
|
data/README.md
CHANGED
|
@@ -8,12 +8,12 @@ The Kiosk Rails engine — host-side surface for [Kiosk](https://kiosk.tech).
|
|
|
8
8
|
The full host-side surface is shipped and covered by the gem's own suite
|
|
9
9
|
(`bundle exec rspec` in this directory runs it and prints how many examples that is):
|
|
10
10
|
|
|
11
|
-
- **Wire-protocol controllers** — each verb route (`GET <mount>/<query-name>`, `POST <mount>/<action-name>`) reaches the operator's controller action that declares the verb, and `Kiosk::Handler` runs the wire's checks around it; `VerbController` refuses a verb route that no action answers; `WireController` serves the two reserved endpoints `GET <mount>/schema` and `POST <mount>/pay`; `VerbController` also serves `POST <mount>/payment_setup` against the payment provider port, and `PaymentSetupController` the page the provider returns the human to; `VerbController` serves `POST <mount>/request_kyc` against the KYC provider port, and `KycCallbackController` the provider's callback; `OpenApiController` serves a derived OpenAPI description of both at `GET <mount>/openapi.json`; `
|
|
12
|
-
- **Account binding** — the claim/link ceremonies bind
|
|
11
|
+
- **Wire-protocol controllers** — each verb route (`GET <mount>/<query-name>`, `POST <mount>/<action-name>`) reaches the operator's controller action that declares the verb, and `Kiosk::Handler` runs the wire's checks around it; `VerbController` refuses a verb route that no action answers; `WireController` serves the two reserved endpoints `GET <mount>/schema` and `POST <mount>/pay`; `VerbController` also serves `POST <mount>/payment_setup` against the payment provider port, and `PaymentSetupController` the page the provider returns the human to; `VerbController` serves `POST <mount>/request_kyc` against the KYC provider port, and `KycCallbackController` the provider's callback; `OpenApiController` serves a derived OpenAPI description of both at `GET <mount>/openapi.json`; `OauthRegistrationController` registers an assistant's public key (RFC 7591) and `OauthTokenController` gives a registered client an access token for a client assertion (RFC 7523) and a DPoP proof (the `client_credentials` grant); `AuthController` puts a new key on a client; every access token is bound to the client's registered key, and a call that carries one also carries a DPoP proof (RFC 9449) that `IdentityResolution` checks; JWKS backs stateless token verification.
|
|
12
|
+
- **Account binding** — the claim/link ceremonies bind a registered client to an existing assistant-account holder's account: the RFC 8628 device authorization and token poll, each authenticated by a client assertion, a session-authenticated verify page and «Link an assistant» page (minimal overridable engine views), link-code mint/redeem, and unlink. The token endpoint gives the tokens; the durable `DeviceAuthorizationStores::ActiveRecord` store (migration 004) is the default.
|
|
13
13
|
- **`Kiosk::Server::Executor`** — dispatches resolved commands to the host's registered queries and Actions.
|
|
14
14
|
- **`Kiosk::Handler`** — the mixin an operator includes into a controller of their own to declare verbs as ordinary Rails actions; each declaration's `kind` says whether it is a query or an action, so one controller may declare both. The engine registers the controllers in `app/controllers/kiosk/` at boot and after every reload (see [Declaring queries and actions](#declaring-queries-and-actions)).
|
|
15
15
|
- **`Kiosk::Server::PaymentClaim`** — the PSP decorator that keeps §11.6's operator half: one capture per payable row, and a paid state anchored to the capture (see [Payments](#payments)).
|
|
16
|
-
- **
|
|
16
|
+
- **Client registration and authentication** — `ClientRegistration`, `ClientAssertion`, `AssistantKey` (the one list of signing algorithms an assistant can use), `AuthToll`, and the pluggable agent-IdP resolve and mint per-agent identities.
|
|
17
17
|
- **PoW gate** — `PowGate` enforces the reputation policy's N×PoW challenge-response (soft dependency on `kiosk-reputation`; zero overhead when no policy is set).
|
|
18
18
|
- **`Kiosk::Server::WellKnown`** — pure-Ruby builder for `/.well-known/kiosk.json`.
|
|
19
19
|
- **`Kiosk::Server::Headers`** + **`HeadersMiddleware`** — Rack middleware that injects `Kiosk-Server-Version`, `Kiosk-API-Version`, `Kiosk-Min-Client` on `/kiosk/*` responses.
|
|
@@ -72,11 +72,16 @@ Kiosk.configure do |c|
|
|
|
72
72
|
c.roles = %i[customer master support]
|
|
73
73
|
# The default role. The engine refuses to boot when `c.roles` is set and
|
|
74
74
|
# this is not one of them. An assistant admitted with no role of its own (a
|
|
75
|
-
#
|
|
75
|
+
# registration, or a binding whose approving human resolves none) gets
|
|
76
76
|
# this one. Name your least-privileged role. An origin that assigns roles to
|
|
77
77
|
# nobody sets neither line, and then no token carries a `role` claim.
|
|
78
78
|
c.registration_role = :customer
|
|
79
79
|
c.owner = { name: "Acme Inc.", support: "support@acme.example" }
|
|
80
|
+
# The Ed25519 key that signs access tokens, checkouts and receipts (EdDSA):
|
|
81
|
+
# openssl genpkey -algorithm ed25519 | base64
|
|
82
|
+
c.signing_key = Base64.decode64(ENV.fetch("KIOSK_SIGNING_KEY_B64"))
|
|
83
|
+
# An assistant key is an Ed25519 key. Add ES256 to also accept a P-256 key.
|
|
84
|
+
# c.assistant_algorithms = %w[EdDSA ES256]
|
|
80
85
|
# c.mount_path = "/kiosk" # default
|
|
81
86
|
# Optional: one event per action invocation. See "The audit sink".
|
|
82
87
|
# c.audit_sink = ->(event) { AuditRow.create!(**event.to_h) }
|
|
@@ -109,11 +114,11 @@ Set a PSP adapter (a `kiosk-pay-*` gem) and the engine serves `pay`. Wrap it in
|
|
|
109
114
|
`Kiosk::Server::PaymentClaim` and the engine keeps §11.6's operator half for you:
|
|
110
115
|
it claims your payable row (`unpaid → paying`) before the capture, so a second
|
|
111
116
|
`pay` for that row is refused before the processor is reached, and marks it
|
|
112
|
-
`paid` when the capture returns.
|
|
113
|
-
|
|
114
|
-
own price for the row. That price is the one thing you write — a
|
|
115
|
-
answers it from your catalog, given the row and the
|
|
116
|
-
|
|
117
|
+
`paid` when the capture returns. The checkout the assistant pays is one your
|
|
118
|
+
origin signed, so under the claim the engine checks one thing: that its total
|
|
119
|
+
is still your own price for the row. That price is the one thing you write — a
|
|
120
|
+
checker that answers it from your catalog, given the row and the checkout's
|
|
121
|
+
item lines, or returns a String saying why the checkout is refused.
|
|
117
122
|
|
|
118
123
|
```ruby
|
|
119
124
|
module PriceChecker
|
|
@@ -124,8 +129,8 @@ Kiosk.configure do |c|
|
|
|
124
129
|
c.payment_provider = Kiosk::Server::PaymentClaim.new(
|
|
125
130
|
psp, currency: "eur", model: "Order", reference: "order_id", query: "my_orders"
|
|
126
131
|
)
|
|
127
|
-
c.
|
|
128
|
-
c.after_payment
|
|
132
|
+
c.checkout_price_checker = PriceChecker
|
|
133
|
+
c.after_payment = ->(order_id) { CourierDispatchJob.arm!(order_id) } # optional
|
|
129
134
|
end
|
|
130
135
|
```
|
|
131
136
|
|
|
@@ -139,10 +144,12 @@ The model also includes `Kiosk::Payable`, which gives each row:
|
|
|
139
144
|
`refunded`. Your per-user query publishes it, so a capture in flight never
|
|
140
145
|
reads as *not paid*. `Order.own.with_settlement(Kiosk::Settlement.own)` reads
|
|
141
146
|
it for many rows in one query.
|
|
142
|
-
- `
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
- `checkout(lines)`: the `checkout_jwt` your action returns, which the
|
|
148
|
+
assistant's AP2 mandates bind. Your origin signs it EdDSA with
|
|
149
|
+
`c.signing_key`, for the row's owner, with a random `jti`. Its first line names the row; then your
|
|
150
|
+
priced lines.
|
|
151
|
+
- `settlement`: the record of the capture that paid the row. `c.after_payment`
|
|
152
|
+
runs once this record is written, so it can refund against it.
|
|
146
153
|
|
|
147
154
|
When the capture returns, the engine pushes `{"order_id": "…", "payment_state":
|
|
148
155
|
"paid"}` to the row's owner on the `payment` topic. Refund through the claim,
|
|
@@ -154,11 +161,18 @@ returns the whole charge, set the row's status column to `refunded`, which is
|
|
|
154
161
|
where `payment_state` reads it. Then call `announce(id, "refunded")` on the
|
|
155
162
|
claim to push the same topic.
|
|
156
163
|
|
|
157
|
-
A repeat of
|
|
158
|
-
the same key, the
|
|
159
|
-
it (`kiosk-pay-stripe` does).
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
A repeat of mandates whose capture did not complete runs the capture again
|
|
165
|
+
under the same key, the checkout mandate's `reference`, so your PSP adapter must
|
|
166
|
+
key its charge by it (`kiosk-pay-stripe` does). One checkout holds one live
|
|
167
|
+
checkout mandate: a second pair for it answers `409 conflict`. A charge refused
|
|
168
|
+
with no money moved marks the checkout mandate declined: it stops counting
|
|
169
|
+
against `spending_cap` at once, a repeat of that pair answers `409 conflict`,
|
|
170
|
+
and a new pair for the same checkout is accepted.
|
|
171
|
+
|
|
172
|
+
A settled `pay` answers the settlement and the two AP2 receipts,
|
|
173
|
+
`checkout_receipt` and `payment_receipt`, which your origin signs EdDSA with
|
|
174
|
+
`c.signing_key`. The payment receipt carries the card network's id of the charge
|
|
175
|
+
when your PSP adapter's `capture` answers `network_confirmation_id`.
|
|
162
176
|
|
|
163
177
|
Set `c.spending_cap = Kiosk::Server::ColumnSpendingCap.new` to cap what each
|
|
164
178
|
assistant spends. The human sets the cap on `/kiosk/auth/assistants`: an amount
|
|
@@ -186,6 +200,20 @@ you honour a booking that takes no payment:
|
|
|
186
200
|
Kiosk::Visit.record!(user_id: booking.user_id, value_cents: 45_00)
|
|
187
201
|
```
|
|
188
202
|
|
|
203
|
+
### Change the signing key
|
|
204
|
+
|
|
205
|
+
`c.signing_key` signs every access token, checkout and receipt. To change it with no
|
|
206
|
+
break, set the new key as `c.signing_key` and the old one as
|
|
207
|
+
`c.previous_signing_key` (a public PEM is enough). The JWKS then publishes
|
|
208
|
+
both, and a token or checkout that either key signed verifies. Remove
|
|
209
|
+
`c.previous_signing_key` after one access-token lifetime, when no checkout that
|
|
210
|
+
the old key signed is still to be paid.
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
c.signing_key = Base64.decode64(ENV.fetch("KIOSK_SIGNING_KEY_B64"))
|
|
214
|
+
c.previous_signing_key = Base64.decode64(ENV.fetch("KIOSK_PREVIOUS_SIGNING_KEY_B64"))
|
|
215
|
+
```
|
|
216
|
+
|
|
189
217
|
|
|
190
218
|
## Upgrading
|
|
191
219
|
|
|
@@ -231,7 +259,9 @@ what can be neither.
|
|
|
231
259
|
The PoW gate keeps spent challenge ids in `c.pow_spent_store`, by default
|
|
232
260
|
`Kiosk::Server::PowSpentStores::ActiveRecord`: the `kiosk.pow_spent` table that
|
|
233
261
|
`bin/rails g kiosk:install` lays down. Every process and every deploy shares it,
|
|
234
|
-
so a proof is accepted once (kiosk.tech `protocol.md` §15.2).
|
|
262
|
+
so a proof is accepted once (kiosk.tech `protocol.md` §15.2). The token
|
|
263
|
+
endpoint keeps the `jti` of each client assertion in the same store, so an
|
|
264
|
+
assertion is accepted once too.
|
|
235
265
|
|
|
236
266
|
Any other backend works: the contract is `claim(id, exp) → Boolean`,
|
|
237
267
|
`release(id)`, `spent?(id)`, `mark_spent(id, exp)`, and `claim` **MUST** be one
|
|
@@ -240,21 +270,12 @@ read-then-write reintroduces exactly the replay race the gate closes.
|
|
|
240
270
|
|
|
241
271
|
Token revocation lives in `c.revocation_store`, by default
|
|
242
272
|
`Kiosk::Server::RevocationStores::ActiveRecord`: the `kiosk.revocations` table,
|
|
243
|
-
which `bin/rails g kiosk:install` lays down too. A
|
|
273
|
+
which `bin/rails g kiosk:install` lays down too. A key rotation, a rebind or an unlink
|
|
244
274
|
stops the agent's older tokens in every process and after a restart, and a
|
|
245
275
|
token of an unlinked agent stops at once. The contract is
|
|
246
276
|
`revoke_all(agent_id, at:)`, `watermark_for(agent_id)` and
|
|
247
277
|
`revoked?(agent_id:, iat:)`.
|
|
248
278
|
|
|
249
|
-
Outstanding auth challenges live in `c.auth_challenge_store`, by default
|
|
250
|
-
`Kiosk::Server::AuthChallengeStores::ActiveRecord`: the `kiosk.auth_challenges`
|
|
251
|
-
table, which `bin/rails g kiosk:install` lays down too. A challenge issued by one
|
|
252
|
-
process is redeemed by any other. Any other backend works: the contract is
|
|
253
|
-
`put(public_key_pem, nonce, exp)` / `take(public_key_pem, nonce) → Boolean`, and
|
|
254
|
-
`take` **MUST** be one atomic operation (SQL `DELETE … RETURNING`, Redis
|
|
255
|
-
`GETDEL`) so the store, not the application, decides which of two concurrent
|
|
256
|
-
presentations wins.
|
|
257
|
-
|
|
258
279
|
Events live in `c.event_store`, by default `Kiosk::Server::EventStores::ActiveRecord`:
|
|
259
280
|
the `kiosk.events` table from `bin/rails g kiosk:install`. The event stream runs
|
|
260
281
|
on the Action Cable adapter that `config/cable.yml` names for the environment. In
|
|
@@ -321,8 +342,8 @@ c.audit_sink = ->(e) { Siem.record(e.to_h.merge(args: e.args.except(:card_token)
|
|
|
321
342
|
|
|
322
343
|
Actions only. A **query** is not emitted (it changes nothing, and every read
|
|
323
344
|
would drown the trail). **`pay`** is not emitted — it already writes the far
|
|
324
|
-
richer AP2 mandate trail (`
|
|
325
|
-
`
|
|
345
|
+
richer AP2 mandate trail (`checkout_mandates`, `payment_mandates`,
|
|
346
|
+
`settlements`). A **refusal that never reached an action**
|
|
326
347
|
is not emitted: a 401 with no identity, a 404 for an unregistered name, a 405,
|
|
327
348
|
a 400 from argument validation, a 402 from the toll. Nothing was invoked, so
|
|
328
349
|
there is nothing to report — this is an action trail, not a request log. Put a
|
|
@@ -368,14 +389,15 @@ action. Each route reaches the controller action that declares the verb, and
|
|
|
368
389
|
not yours. Under the mount: the reserved `schema`, `openapi.json` and `pay`,
|
|
369
390
|
`payment_setup` and the `payment_setup/return` page a payment provider sends the
|
|
370
391
|
human back to (both answer `501 module_not_served` without a `payment_provider`), the
|
|
371
|
-
|
|
372
|
-
`auth/
|
|
392
|
+
OAuth registration and token endpoints (`oauth/register`, `oauth/token`), the key
|
|
393
|
+
rotation (`auth/rotate`), JWKS (`.well-known/jwks.json`), KYC (`agents/kyc`, and
|
|
373
394
|
`request_kyc` and `kyc/callback`, which answer `501 module_not_served` without a
|
|
374
395
|
`kyc_provider`) and the whole account-binding ceremony (the RFC 8628 claim wire,
|
|
375
396
|
`auth/link`/`claim`/`unlink`, the verify and «Link an assistant» pages). The
|
|
376
397
|
engine also installs the ROOT-relative discovery documents — `/agents.txt`,
|
|
377
398
|
`/agents.json`, `/auth.md`,
|
|
378
|
-
`/.well-known/{
|
|
399
|
+
`/.well-known/{kiosk.json,api-catalog,oauth-authorization-server,ard.json}` and
|
|
400
|
+
`/.well-known/agent-skills/index.json` — into the host app
|
|
379
401
|
via `routes.append`, because the agents.txt standard and RFC 8615 place them at
|
|
380
402
|
the origin root, outside any mount prefix. That install happens ONLY when the
|
|
381
403
|
engine is mounted: merely bundling the gem adds no routes.
|
|
@@ -558,10 +580,10 @@ the one rendered whatever `Accept` the caller sent. On top of that:
|
|
|
558
580
|
- `include Kiosk::SkillLink` — in a page controller: every answer carries
|
|
559
581
|
`Link: <skill_url>; rel="kiosk"`, the discovery signal for a client that
|
|
560
582
|
does not read HTML.
|
|
561
|
-
- `Kiosk::Settlement` and `Kiosk::
|
|
562
|
-
`pay`
|
|
563
|
-
`Kiosk::Settlement.joins(:
|
|
564
|
-
is the settlement whose
|
|
583
|
+
- `Kiosk::Settlement` and `Kiosk::CheckoutMandate` — read models over the
|
|
584
|
+
records `pay` writes. `Kiosk::Settlement.own` is the caller's own;
|
|
585
|
+
`Kiosk::Settlement.joins(:checkout_mandate).merge(Kiosk::CheckoutMandate.referencing(order_id: id))`
|
|
586
|
+
is the settlement whose checkout names your row by the key your line items carry.
|
|
565
587
|
- The handler runs inside the wire's GUC-scoped transaction, so raising rolls
|
|
566
588
|
back — and so does rendering a non-2xx, which the seam converts into a raise.
|
|
567
589
|
|
|
@@ -617,7 +639,7 @@ the PoW gates.
|
|
|
617
639
|
```ruby
|
|
618
640
|
require "kiosk/server"
|
|
619
641
|
|
|
620
|
-
# The issuer is the
|
|
642
|
+
# The issuer is the origin a token or a checkout names; the builder refuses to answer
|
|
621
643
|
# without it, booted app or not.
|
|
622
644
|
Kiosk.configure { |c| c.issuer = "https://api.acme.example" }
|
|
623
645
|
|
|
@@ -5,8 +5,8 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
module Kiosk
|
|
7
7
|
module Server
|
|
8
|
-
# The
|
|
9
|
-
#
|
|
8
|
+
# The Kiosk auth paths that no OAuth standard covers (§5.7, §6), drawn by the
|
|
9
|
+
# engine: the key rotation, and the account-binding link, claim and unlink.
|
|
10
10
|
class AuthController < ::ActionController::API
|
|
11
11
|
# The JSON answers and RFC 9457 problem documents of the auth paths.
|
|
12
12
|
module Answers
|
|
@@ -37,7 +37,7 @@ module Kiosk
|
|
|
37
37
|
issuer = Kiosk.current_issuer
|
|
38
38
|
case err
|
|
39
39
|
when Errors::PowRequired then %(Kiosk-PoW realm="#{issuer}")
|
|
40
|
-
when Errors::PaymentSetupRequired then %(Payment realm="#{issuer}"
|
|
40
|
+
when Errors::PaymentSetupRequired then %(Kiosk-Payment-Setup realm="#{issuer}")
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
end
|
|
@@ -61,52 +61,21 @@ module Kiosk
|
|
|
61
61
|
|
|
62
62
|
prepend_before_action :refuse_unserved_binding, only: %i[link claim unlink]
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
public_key = request.query_parameters['public_key']
|
|
67
|
-
raise Errors::BadRequest, 'missing public_key query parameter' if public_key.blank?
|
|
64
|
+
# A refused client assertion is a 401, with the hint that says what to fix.
|
|
65
|
+
rescue_from(ClientAssertion::Refused) { |e| render_error(e.to_problem) }
|
|
68
66
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# header, so the signed body stays the same on retry.
|
|
76
|
-
def register
|
|
77
|
-
body = parse_body!('POST <endpoint>/auth/register')
|
|
78
|
-
pow = proofs
|
|
79
|
-
result = AgentRegistration.call(public_key_pem: body.fetch(:public_key), signed: body.fetch(:signed), pow: pow)
|
|
80
|
-
respond(result, :created)
|
|
67
|
+
# Puts a new key on a registered client; both keys sign (§5.7). Answers the client information.
|
|
68
|
+
def rotate
|
|
69
|
+
body = parse_body!('POST <endpoint>/auth/rotate')
|
|
70
|
+
client = AgentRotation.call(type: body.fetch(:client_assertion_type), assertion: body.fetch(:client_assertion),
|
|
71
|
+
new_assertion: body.fetch(:new_client_assertion), jwks: body.fetch(:jwks))
|
|
72
|
+
respond(client.information, :ok)
|
|
81
73
|
rescue KeyError => e
|
|
82
74
|
render_error(Errors.missing_field(e))
|
|
83
75
|
rescue Errors::Base => e
|
|
84
76
|
render_error(e)
|
|
85
77
|
end
|
|
86
78
|
|
|
87
|
-
# Refresh a token for an EXISTING public key (404 if unknown → register).
|
|
88
|
-
def login
|
|
89
|
-
body = parse_body!('POST <endpoint>/auth/login')
|
|
90
|
-
result = AgentLogin.call(public_key_pem: body.fetch(:public_key), signed: body.fetch(:signed))
|
|
91
|
-
respond(result, :ok)
|
|
92
|
-
rescue KeyError => e
|
|
93
|
-
render_error(Errors.missing_field(e))
|
|
94
|
-
rescue Errors::Base => e
|
|
95
|
-
render_error(e)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Revokes every token of the caller's agent and returns a fresh one.
|
|
99
|
-
def revoke
|
|
100
|
-
identity = authenticated_agent
|
|
101
|
-
raise Errors::Unauthenticated, 'agent authentication required' if identity.nil? || identity.agent_id.nil?
|
|
102
|
-
|
|
103
|
-
Kiosk.configuration.revocation_store.revoke_all(identity.agent_id, at: Time.now.to_i)
|
|
104
|
-
token = fresh_token(identity)
|
|
105
|
-
respond({ access_token: token }, :ok)
|
|
106
|
-
rescue Errors::Base => e
|
|
107
|
-
render_error(e)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
79
|
# Mints a link code for the signed-in holder; the holder's `user_idp` role becomes the bound assistant's.
|
|
111
80
|
def link
|
|
112
81
|
identity = authenticated_account_holder!
|
|
@@ -116,15 +85,14 @@ module Kiosk
|
|
|
116
85
|
render_error(e)
|
|
117
86
|
end
|
|
118
87
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# The optional PoW proof rides in the `Kiosk-PoW` header, as on register.
|
|
88
|
+
# Binds the client to the human who minted the code; answers 204. The
|
|
89
|
+
# optional toll's proofs ride in the `Kiosk-PoW` header.
|
|
123
90
|
def claim
|
|
124
|
-
body
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
91
|
+
body = parse_body!('POST <endpoint>/auth/claim')
|
|
92
|
+
LinkCode.redeem(code: body.fetch(:code), assertion_type: body.fetch(:client_assertion_type),
|
|
93
|
+
assertion: body.fetch(:client_assertion), pow: proofs)
|
|
94
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
95
|
+
head :no_content
|
|
128
96
|
rescue KeyError => e
|
|
129
97
|
render_error(Errors.missing_field(e))
|
|
130
98
|
rescue Errors::Base => e
|
|
@@ -146,11 +114,6 @@ module Kiosk
|
|
|
146
114
|
|
|
147
115
|
private
|
|
148
116
|
|
|
149
|
-
# Auth endpoints always mint through the bundled DefaultAgentIdp.
|
|
150
|
-
def fresh_token(identity)
|
|
151
|
-
AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: identity.agent_id, role: identity.role)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
117
|
# The human's side of binding authenticates by the provider's session, never by an agent token.
|
|
155
118
|
# A page on another origin can post a form with that cookie, but cannot send JSON without a CORS preflight.
|
|
156
119
|
def authenticated_account_holder!
|
|
@@ -162,13 +125,6 @@ module Kiosk
|
|
|
162
125
|
identity
|
|
163
126
|
end
|
|
164
127
|
|
|
165
|
-
# nil for a missing, invalid or revoked bearer token.
|
|
166
|
-
def authenticated_agent
|
|
167
|
-
IdentityResolution.agent_idp.verify(request)
|
|
168
|
-
rescue Kiosk::Server::JwtIssuer::Error, Kiosk::AgentIdentityProviders::InvalidToken
|
|
169
|
-
nil
|
|
170
|
-
end
|
|
171
|
-
|
|
172
128
|
# Schema-checked before any `fetch`, so a wrong-typed member is a 400 naming it (§17).
|
|
173
129
|
def parse_body!(exchange)
|
|
174
130
|
raw = request.raw_post
|
|
@@ -23,8 +23,24 @@ module Kiosk
|
|
|
23
23
|
render json: WellKnown.agents_json(base_url: request.base_url)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
render json: WellKnown.
|
|
26
|
+
def oauth_authorization_server
|
|
27
|
+
render json: WellKnown.oauth_authorization_server(base_url: request.base_url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def oauth_protected_resource
|
|
31
|
+
allow_cors
|
|
32
|
+
render json: WellKnown.oauth_protected_resource(base_url: request.base_url)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def agent_skills_index
|
|
36
|
+
allow_cors
|
|
37
|
+
render_or_not_found WellKnown.agent_skills_index
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ard
|
|
41
|
+
allow_cors
|
|
42
|
+
short_ttl
|
|
43
|
+
render json: WellKnown.ard(base_url: request.base_url)
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
def kiosk_json
|
|
@@ -35,6 +51,8 @@ module Kiosk
|
|
|
35
51
|
def api_catalog
|
|
36
52
|
allow_cors
|
|
37
53
|
short_ttl
|
|
54
|
+
# RFC 9727 §2: a HEAD request finds the catalog by this header.
|
|
55
|
+
response.set_header('Link', %(<#{request.base_url}/.well-known/api-catalog>; rel="api-catalog"))
|
|
38
56
|
render json: WellKnown.api_catalog(base_url: request.base_url),
|
|
39
57
|
content_type: 'application/linkset+json; ' \
|
|
40
58
|
'profile="https://www.rfc-editor.org/info/rfc9727"'
|
|
@@ -48,7 +66,11 @@ module Kiosk
|
|
|
48
66
|
|
|
49
67
|
private
|
|
50
68
|
|
|
51
|
-
|
|
69
|
+
def render_or_not_found(document)
|
|
70
|
+
document ? render(json: document) : head(:not_found)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# agents.txt v1.0 and AI Catalog require `Access-Control-Allow-Origin: *`.
|
|
52
74
|
def allow_cors
|
|
53
75
|
response.set_header('Access-Control-Allow-Origin', '*')
|
|
54
76
|
end
|
|
@@ -4,11 +4,11 @@ require 'action_controller'
|
|
|
4
4
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
|
-
# GET <endpoint>/.well-known/jwks.json — the
|
|
7
|
+
# GET <endpoint>/.well-known/jwks.json — the current signing key, and the previous one while it is set.
|
|
8
8
|
class JwksController < ::ActionController::API
|
|
9
9
|
def show
|
|
10
10
|
Kiosk::Server::Headers.add_to(response.headers)
|
|
11
|
-
render json: Kiosk::Server::Jwks.build(keys:
|
|
11
|
+
render json: Kiosk::Server::Jwks.build(keys: Kiosk.configuration.signing_keys)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -49,10 +49,7 @@ module Kiosk
|
|
|
49
49
|
|
|
50
50
|
# Only an assistant submits an attestation, so only the agent IdP answers.
|
|
51
51
|
def authenticate!
|
|
52
|
-
|
|
53
|
-
raise Errors::Unauthenticated, 'missing or invalid agent token' if identity.nil?
|
|
54
|
-
|
|
55
|
-
identity
|
|
52
|
+
IdentityResolution.resolve(request) || raise(Errors::AccessTokenRefused)
|
|
56
53
|
end
|
|
57
54
|
|
|
58
55
|
def render_error(err)
|
|
@@ -4,41 +4,28 @@ require 'action_controller'
|
|
|
4
4
|
|
|
5
5
|
module Kiosk
|
|
6
6
|
module Server
|
|
7
|
-
# POST <endpoint>/oauth/device_authorization (RFC 8628 §3.1):
|
|
8
|
-
# opens the claim half of account binding
|
|
7
|
+
# POST <endpoint>/oauth/device_authorization (RFC 8628 §3.1): a registered
|
|
8
|
+
# client opens the claim half of account binding. It authenticates with a
|
|
9
|
+
# client assertion, so it sends no `client_id` of its own.
|
|
9
10
|
class OauthDeviceAuthorizationController < ::ActionController::API
|
|
10
11
|
include BindingModuleGate
|
|
12
|
+
include OauthAnswers
|
|
11
13
|
|
|
12
14
|
prepend_before_action :refuse_unserved_binding
|
|
13
15
|
|
|
14
16
|
def create
|
|
15
|
-
problem =
|
|
16
|
-
|
|
17
|
+
problem = role_problem
|
|
18
|
+
raise OauthError.new(:invalid_request, problem) if problem
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
render_oauth(device_authorization(DeviceCodeGrant.start(client: authenticate_client!)))
|
|
21
|
+
rescue OauthError => e
|
|
22
|
+
render_oauth_error(e)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
return 'client_id parameter required' if params[:client_id].to_s.empty?
|
|
28
|
-
return 'public_key parameter required' if params[:public_key].to_s.empty?
|
|
29
|
-
|
|
30
|
-
key_problem(params[:public_key].to_s) || role_problem
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def key_problem(public_key)
|
|
34
|
-
PopVerifier.load_public_key(public_key.strip)
|
|
35
|
-
nil
|
|
36
|
-
rescue Errors::BadRequest => e
|
|
37
|
-
e.message
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Refused, not ignored: the role is the approving human's, and this
|
|
41
|
-
# request is unauthenticated.
|
|
27
|
+
# Refused, not ignored: the role is the approving human's, and the
|
|
28
|
+
# assistant does not choose it.
|
|
42
29
|
def role_problem
|
|
43
30
|
offending = %i[role scope].find { |name| params.key?(name) }
|
|
44
31
|
return unless offending
|
|
@@ -63,11 +50,6 @@ module Kiosk
|
|
|
63
50
|
mount = Kiosk.configuration.mount_path
|
|
64
51
|
"#{request.base_url}#{mount}/oauth/device/verify"
|
|
65
52
|
end
|
|
66
|
-
|
|
67
|
-
def render_oauth_error(code, description, status:)
|
|
68
|
-
Kiosk::Server::Headers.add_to(response.headers)
|
|
69
|
-
render json: { error: code.to_s, error_description: description }, status: status
|
|
70
|
-
end
|
|
71
53
|
end
|
|
72
54
|
end
|
|
73
55
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# POST <endpoint>/oauth/register (RFC 7591 Section 3): open registration of
|
|
9
|
+
# an AI assistant's public key. The optional toll's proofs ride in the
|
|
10
|
+
# `Kiosk-PoW` header, so the body stays the same on retry.
|
|
11
|
+
class OauthRegistrationController < ::ActionController::API
|
|
12
|
+
include AuthController::Answers
|
|
13
|
+
include AuthController::Proofs
|
|
14
|
+
include OauthAnswers
|
|
15
|
+
|
|
16
|
+
def create
|
|
17
|
+
client = ClientRegistration.call(metadata, pow: proofs)
|
|
18
|
+
render_oauth(client.information, status: :created)
|
|
19
|
+
rescue OauthError => e
|
|
20
|
+
render_oauth_error(e)
|
|
21
|
+
rescue Errors::Base => e
|
|
22
|
+
render_error(e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def metadata
|
|
28
|
+
JSON.parse(request.raw_post.to_s)
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
raise OauthError.new(:invalid_client_metadata, 'the request body must be a JSON object')
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|