kiosk-server 0.5.20 → 0.6.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +192 -138
- data/app/channels/kiosk_events.rb +30 -17
- data/app/controllers/kiosk/server/application_controller.rb +0 -1
- data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
- data/app/controllers/kiosk/server/auth_controller.rb +21 -65
- data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
- data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
- 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/kyc_callback_controller.rb +3 -1
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
- data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
- data/app/controllers/kiosk/server/wire_controller.rb +2 -2
- data/app/models/kiosk/checkout_mandate.rb +20 -0
- data/app/models/kiosk/settlement.rb +1 -3
- data/app/models/kiosk/visit.rb +0 -2
- data/app/views/kiosk/server/assistants/show.html.erb +20 -24
- data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
- data/app/views/layouts/kiosk/server/application.html.erb +14 -0
- data/lib/generators/kiosk/install/install_generator.rb +25 -69
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +32 -62
- data/lib/kiosk/server/account_holder_gate.rb +3 -1
- 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/argument_decoder.rb +2 -2
- data/lib/kiosk/server/assistant_accounts.rb +38 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +6 -6
- 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 +154 -0
- data/lib/kiosk/server/client_registration.rb +98 -0
- data/lib/kiosk/server/column_spending_cap.rb +1 -1
- data/lib/kiosk/server/configuration_extension.rb +64 -75
- data/lib/kiosk/server/current_request.rb +1 -1
- data/lib/kiosk/server/device_authorization.rb +1 -1
- data/lib/kiosk/server/device_authorization_stores.rb +1 -1
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +126 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +16 -13
- data/lib/kiosk/server/errors.rb +28 -7
- data/lib/kiosk/server/event_stores.rb +2 -2
- data/lib/kiosk/server/events.rb +4 -3
- data/lib/kiosk/server/events_connection.rb +38 -10
- data/lib/kiosk/server/events_replay.rb +79 -0
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +17 -76
- data/lib/kiosk/server/executor.rb +86 -105
- data/lib/kiosk/server/failure_log.rb +3 -3
- data/lib/kiosk/server/handler_mixin.rb +9 -9
- data/lib/kiosk/server/handler_registrations.rb +1 -1
- data/lib/kiosk/server/handler_serving.rb +11 -11
- data/lib/kiosk/server/headers.rb +3 -3
- 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.rb +5 -7
- data/lib/kiosk/server/kyc_verifier.rb +28 -29
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +22 -17
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/money.rb +41 -0
- data/lib/kiosk/server/oauth_answers.rb +31 -0
- data/lib/kiosk/server/oauth_error.rb +33 -0
- data/lib/kiosk/server/open_api.rb +31 -29
- data/lib/kiosk/server/page.rb +1 -1
- data/lib/kiosk/server/payment_claim.rb +47 -78
- data/lib/kiosk/server/pow_gate.rb +12 -33
- data/lib/kiosk/server/pow_spent_stores.rb +1 -1
- data/lib/kiosk/server/receipts.rb +30 -0
- data/lib/kiosk/server/reputation_factors.rb +12 -5
- data/lib/kiosk/server/request_validation.rb +3 -3
- data/lib/kiosk/server/result.rb +1 -1
- data/lib/kiosk/server/revocation_stores.rb +9 -1
- data/lib/kiosk/server/routing.rb +1 -1
- data/lib/kiosk/server/schemas/auth.schema.json +369 -58
- data/lib/kiosk/server/schemas/binding.schema.json +24 -59
- data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
- data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
- data/lib/kiosk/server/schemas/pow.schema.json +2 -2
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/session_context.rb +2 -2
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/tolled_calls.rb +46 -0
- data/lib/kiosk/server/verb_wire.rb +4 -4
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +272 -205
- data/lib/kiosk/server/wire_rendering.rb +2 -2
- data/lib/kiosk/server.rb +8 -2
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +34 -24
- 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/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
- 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/schema_definitions.rb +0 -351
- data/lib/kiosk/server/settled_spend.rb +0 -21
- data/lib/kiosk/server/wrong_door.rb +0 -42
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'action_cable'
|
|
4
4
|
|
|
5
5
|
# Top level, unsuffixed, because Action Cable constantizes the channel named
|
|
6
|
-
# in a subscribe frame: this class name IS the
|
|
6
|
+
# in a subscribe frame: this class name IS the protocol's channel name.
|
|
7
7
|
#
|
|
8
8
|
# {"command":"subscribe",
|
|
9
9
|
# "identifier":"{\"channel\":\"KioskEvents\",\"topic\":\"todo\",\"subject\":\"list_4f1e…\"}"}
|
|
@@ -28,15 +28,14 @@ class KioskEvents < ActionCable::Channel::Base
|
|
|
28
28
|
assign(declaration)
|
|
29
29
|
return reject unless reachable?(declaration)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
transmit(subscribed_frame)
|
|
31
|
+
transmit(open_delivery!)
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
# Action Cable confirms once the pubsub subscription is live, so replay
|
|
36
35
|
# starts here: an event emitted before that reaches the client this way.
|
|
37
36
|
def transmit_subscription_confirmation
|
|
38
37
|
super
|
|
39
|
-
replay
|
|
38
|
+
@replay.confirmed
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
private
|
|
@@ -59,13 +58,10 @@ class KioskEvents < ActionCable::Channel::Base
|
|
|
59
58
|
}
|
|
60
59
|
end
|
|
61
60
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
store.since(identity_key, since || @head).each do |event|
|
|
67
|
-
transmit(event) if for_this_subscription?(event)
|
|
68
|
-
end
|
|
61
|
+
# Replay and live delivery both come here, through {Kiosk::Server::EventsReplay}.
|
|
62
|
+
# An event can arrive twice; the client ignores an `id` it has seen.
|
|
63
|
+
def deliver(event)
|
|
64
|
+
transmit(event) if for_this_subscription?(event)
|
|
69
65
|
end
|
|
70
66
|
|
|
71
67
|
def since = params[:since]
|
|
@@ -80,17 +76,33 @@ class KioskEvents < ActionCable::Channel::Base
|
|
|
80
76
|
(since.nil? || cursor?(since)) && (params[:subject].nil? || params[:subject].is_a?(String))
|
|
81
77
|
end
|
|
82
78
|
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
# Reads head before the stream opens, so no event falls between head and
|
|
80
|
+
# stream. Returns the `subscribed` frame, built before the replay group
|
|
81
|
+
# counts this subscription.
|
|
82
|
+
def open_delivery!
|
|
85
83
|
@head = store.head
|
|
84
|
+
subscribed_frame.tap do
|
|
85
|
+
@replay = connection.kiosk_replay_member(since || @head) { |event| deliver(event) }
|
|
86
|
+
open_stream!
|
|
87
|
+
end
|
|
88
|
+
end
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
# Without `coder:` the block receives the raw broadcast String.
|
|
91
|
+
def open_stream!
|
|
88
92
|
stream_from Kiosk::Server::EventsCable.stream_name(identity_key, @topic),
|
|
89
93
|
coder: ActiveSupport::JSON do |event|
|
|
90
|
-
|
|
94
|
+
@replay.live(event)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
logger&.error("[kiosk] events delivery failed for topic #{@topic.inspect}: #{e.class}: #{e.message}")
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
|
|
100
|
+
# On the pubsub's own thread, in the order it hands events over (spec
|
|
101
|
+
# Section 8.5.5). Action Cable's worker pool would reorder them.
|
|
102
|
+
def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
|
|
103
|
+
stream_handler(broadcasting, user_handler, coder:)
|
|
104
|
+
end
|
|
105
|
+
|
|
94
106
|
def cursor?(value) = value.is_a?(Integer) && value.between?(0, MAX_CURSOR)
|
|
95
107
|
|
|
96
108
|
# Its own topic (the replayed tail holds every topic) and, when named, its
|
|
@@ -124,14 +136,15 @@ class KioskEvents < ActionCable::Channel::Base
|
|
|
124
136
|
end
|
|
125
137
|
|
|
126
138
|
# Runs every REAUTHORISE_EVERY_SECONDS. A withdrawn reach is final for this
|
|
127
|
-
# subscription
|
|
139
|
+
# subscription: the frame is sent once, and the subscription leaves the
|
|
140
|
+
# connection, so a later subscribe is judged again rather than confirmed as live.
|
|
128
141
|
def reauthorise!
|
|
129
142
|
return if @declaration.nil? || reachable?(@declaration)
|
|
130
143
|
|
|
131
144
|
@declaration = nil
|
|
132
145
|
# Braces required: bare pairs would be read as `transmit`'s keywords.
|
|
133
146
|
transmit({ 'type' => 'unsubscribed', 'topic' => @topic, 'reason' => 'reach_revoked' })
|
|
134
|
-
|
|
147
|
+
connection.subscriptions.remove_subscription(self)
|
|
135
148
|
rescue StandardError => e
|
|
136
149
|
logger&.error("[kiosk] events re-authorisation failed: #{e.class}: #{e.message}")
|
|
137
150
|
end
|
|
@@ -8,11 +8,8 @@ module Kiosk
|
|
|
8
8
|
class AssistantsController < ApplicationController
|
|
9
9
|
SIGN_IN_PROMPT = 'Sign in to your account first to manage linked assistants.'
|
|
10
10
|
SIGN_IN_ALERT = 'Please sign in to manage your linked assistants.'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
'it needs a signed-in session and a CSRF token from its own form'
|
|
14
|
-
|
|
15
|
-
layout false
|
|
11
|
+
CAP_NOT_AN_AMOUNT = 'Write the spending cap as an amount, such as 50 or 12.50.'
|
|
12
|
+
CURRENCY_NOT_A_CODE = 'Write the cap currency as a three-letter currency code, such as EUR.'
|
|
16
13
|
|
|
17
14
|
def show
|
|
18
15
|
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
@@ -41,7 +38,7 @@ module Kiosk
|
|
|
41
38
|
render_error(e)
|
|
42
39
|
end
|
|
43
40
|
|
|
44
|
-
# Edits a bound assistant's label and/or spending cap and its currency (blank cap →
|
|
41
|
+
# Edits a bound assistant's label and/or spending cap and its currency (blank cap → no cap).
|
|
45
42
|
def update
|
|
46
43
|
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
47
44
|
|
|
@@ -62,8 +59,9 @@ module Kiosk
|
|
|
62
59
|
|
|
63
60
|
def render_page(status: :ok)
|
|
64
61
|
@assistants = LinkedAssistants.list(@identity.user_id)
|
|
65
|
-
#
|
|
66
|
-
@
|
|
62
|
+
# The spend shows where the origin takes payments; the cap, where a `spending_cap` reads it.
|
|
63
|
+
@payments = !Kiosk.configuration.payment_provider.nil?
|
|
64
|
+
@spending_cap = !Kiosk.configuration.spending_cap.nil?
|
|
67
65
|
@page_path = request.path.sub(%r{/(link|unlink|update)\z}, '')
|
|
68
66
|
render :show, status: status
|
|
69
67
|
end
|
|
@@ -72,24 +70,25 @@ module Kiosk
|
|
|
72
70
|
def settings_changes
|
|
73
71
|
changes = {}
|
|
74
72
|
changes['human_label'] = params[:human_label].to_s if params.key?(:human_label)
|
|
75
|
-
changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:
|
|
73
|
+
changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:spending_cap)
|
|
76
74
|
changes
|
|
77
75
|
end
|
|
78
76
|
|
|
79
|
-
# `[cents, currency]
|
|
77
|
+
# `[cents, currency]` from the amount the person typed, or `[nil, nil]` for no cap.
|
|
80
78
|
def spending_cap_param
|
|
81
|
-
raw = params[:
|
|
79
|
+
raw = params[:spending_cap].to_s
|
|
82
80
|
return [nil, nil] if raw.strip.empty?
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
currency = spending_cap_currency_param
|
|
83
|
+
cents = Money.parse(raw, currency) || raise(Errors::BadRequest, CAP_NOT_AN_AMOUNT)
|
|
84
|
+
[cents, currency]
|
|
86
85
|
end
|
|
87
86
|
|
|
88
87
|
def spending_cap_currency_param
|
|
89
88
|
code = MandateVerifier.canonical_currency(params[:spending_cap_currency])
|
|
90
89
|
return code if MandateVerifier::CURRENCY_CODE.match?(code)
|
|
91
90
|
|
|
92
|
-
raise Errors::BadRequest,
|
|
91
|
+
raise Errors::BadRequest, CURRENCY_NOT_A_CODE
|
|
93
92
|
end
|
|
94
93
|
end
|
|
95
94
|
end
|
|
@@ -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
|
|
@@ -17,7 +17,7 @@ module Kiosk
|
|
|
17
17
|
render json: payload, status: status
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# The same RFC 9457 problem document as the
|
|
20
|
+
# The same RFC 9457 problem document as the protocol (§9).
|
|
21
21
|
def render_error(err)
|
|
22
22
|
add_problem_headers(err)
|
|
23
23
|
render json: err.to_problem, status: err.http_status,
|
|
@@ -32,12 +32,12 @@ module Kiosk
|
|
|
32
32
|
response.set_header('WWW-Authenticate', challenge) if challenge
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
# RFC 7235 challenge header for the 402s, as
|
|
35
|
+
# RFC 7235 challenge header for the 402s, as in the protocol.
|
|
36
36
|
def www_authenticate_for(err)
|
|
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,29 +114,17 @@ 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!
|
|
157
120
|
identity = Kiosk.configuration.user_idp&.verify(request)
|
|
158
121
|
hint = "sign in to the provider first — this endpoint authenticates via the provider's own session"
|
|
159
|
-
raise Errors::
|
|
122
|
+
raise Errors::SessionRequired.new('account session required', hint:) unless identity
|
|
160
123
|
raise Errors::BadRequest, 'Content-Type must be application/json' unless request.media_type == 'application/json'
|
|
161
124
|
|
|
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
|
|
@@ -11,14 +11,9 @@ module Kiosk
|
|
|
11
11
|
|
|
12
12
|
CODE_NOT_RECOGNISED = 'That code was not recognised — it may have expired. Ask the assistant for a fresh one.'
|
|
13
13
|
|
|
14
|
-
WRONG_DOOR_MESSAGE = "this is the account holder's browser consent page, not the Kiosk wire — " \
|
|
15
|
-
'it needs a signed-in session and a CSRF token from its own form'
|
|
16
|
-
|
|
17
14
|
# Failed code lookups per session before a 429, against 31^8 possible codes.
|
|
18
15
|
MAX_CODE_ATTEMPTS = 10
|
|
19
16
|
|
|
20
|
-
layout false
|
|
21
|
-
|
|
22
17
|
def show
|
|
23
18
|
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
24
19
|
return render_attempts_capped if attempts_capped?
|
|
@@ -10,21 +10,33 @@ module Kiosk
|
|
|
10
10
|
# Rails stamps `Vary: Accept` on a negotiated render; these documents are
|
|
11
11
|
# one answer for everybody, so a shared cache must keep one copy.
|
|
12
12
|
after_action :drop_vary
|
|
13
|
+
before_action :allow_cors
|
|
13
14
|
|
|
14
15
|
def agents_txt
|
|
15
|
-
allow_cors
|
|
16
16
|
render plain: WellKnown.agents_txt(base_url: request.base_url),
|
|
17
17
|
content_type: 'text/plain; charset=utf-8'
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def agents_json
|
|
21
|
-
allow_cors
|
|
22
21
|
short_ttl
|
|
23
22
|
render json: WellKnown.agents_json(base_url: request.base_url)
|
|
24
23
|
end
|
|
25
24
|
|
|
26
|
-
def
|
|
27
|
-
render json: WellKnown.
|
|
25
|
+
def oauth_authorization_server
|
|
26
|
+
render json: WellKnown.oauth_authorization_server(base_url: request.base_url)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def oauth_protected_resource
|
|
30
|
+
render json: WellKnown.oauth_protected_resource(base_url: request.base_url)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def agent_skills_index
|
|
34
|
+
render_or_not_found WellKnown.agent_skills_index
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ard
|
|
38
|
+
short_ttl
|
|
39
|
+
render json: WellKnown.ard(base_url: request.base_url)
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
def kiosk_json
|
|
@@ -33,22 +45,27 @@ module Kiosk
|
|
|
33
45
|
end
|
|
34
46
|
|
|
35
47
|
def api_catalog
|
|
36
|
-
allow_cors
|
|
37
48
|
short_ttl
|
|
49
|
+
# RFC 9727 §2: a HEAD request finds the catalog by this header.
|
|
50
|
+
response.set_header('Link', %(<#{request.base_url}/.well-known/api-catalog>; rel="api-catalog"))
|
|
38
51
|
render json: WellKnown.api_catalog(base_url: request.base_url),
|
|
39
52
|
content_type: 'application/linkset+json; ' \
|
|
40
53
|
'profile="https://www.rfc-editor.org/info/rfc9727"'
|
|
41
54
|
end
|
|
42
55
|
|
|
43
56
|
def auth_md
|
|
44
|
-
allow_cors
|
|
45
57
|
render plain: WellKnown.auth_md(base_url: request.base_url),
|
|
46
58
|
content_type: 'text/markdown; charset=utf-8'
|
|
47
59
|
end
|
|
48
60
|
|
|
49
61
|
private
|
|
50
62
|
|
|
51
|
-
|
|
63
|
+
def render_or_not_found(document)
|
|
64
|
+
document ? render(json: document) : head(:not_found)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# agents.txt v1.0 and AI Catalog require `Access-Control-Allow-Origin: *`.
|
|
68
|
+
# Every document here answers it, so a page on any origin can read it.
|
|
52
69
|
def allow_cors
|
|
53
70
|
response.set_header('Access-Control-Allow-Origin', '*')
|
|
54
71
|
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)
|
|
@@ -7,7 +7,9 @@ module Kiosk
|
|
|
7
7
|
class KycCallbackController < WireController
|
|
8
8
|
def create
|
|
9
9
|
Kyc.served!
|
|
10
|
-
|
|
10
|
+
body = parse_body!
|
|
11
|
+
RequestValidation.validate_body!(body, exchange: 'POST <endpoint>/kyc/callback')
|
|
12
|
+
Kyc.callback(body)
|
|
11
13
|
render json: { ok: true }, status: :ok
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -4,48 +4,36 @@ 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
|
-
|
|
28
|
-
|
|
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
|
+
# A `role` is refused: the role is the approving human's, and the
|
|
28
|
+
# assistant does not choose it. A `scope` is ignored (RFC 6749 Section
|
|
29
|
+
# 3.3): an assistant gets another role only when a human who holds that
|
|
30
|
+
# role binds it to their account, or through the identity provider.
|
|
42
31
|
def role_problem
|
|
43
|
-
|
|
44
|
-
return unless offending
|
|
32
|
+
return unless params.key?(:role)
|
|
45
33
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
34
|
+
'role is not accepted here: an assistant does not choose its own role. ' \
|
|
35
|
+
'The role of a bound assistant is the role of the account holder who approves, ' \
|
|
36
|
+
"and this provider's identity system gives it at the verify page."
|
|
49
37
|
end
|
|
50
38
|
|
|
51
39
|
def device_authorization(result)
|
|
@@ -63,11 +51,6 @@ module Kiosk
|
|
|
63
51
|
mount = Kiosk.configuration.mount_path
|
|
64
52
|
"#{request.base_url}#{mount}/oauth/device/verify"
|
|
65
53
|
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
54
|
end
|
|
72
55
|
end
|
|
73
56
|
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
|