kiosk-server 0.5.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- metadata +349 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_cable'
|
|
4
|
+
|
|
5
|
+
# Top level, unsuffixed, because Action Cable constantizes the channel named
|
|
6
|
+
# in a subscribe frame: this class name IS the wire's channel name.
|
|
7
|
+
#
|
|
8
|
+
# {"command":"subscribe",
|
|
9
|
+
# "identifier":"{\"channel\":\"KioskEvents\",\"topic\":\"todo\",\"subject\":\"list_4f1e…\"}"}
|
|
10
|
+
#
|
|
11
|
+
class KioskEvents < ActionCable::Channel::Base
|
|
12
|
+
# The subject's reach is re-checked while the subscription stands (spec
|
|
13
|
+
# Section 8.5.6); the credential is the connection's to re-check.
|
|
14
|
+
REAUTHORISE_EVERY_SECONDS = 30
|
|
15
|
+
|
|
16
|
+
periodically :reauthorise!, every: REAUTHORISE_EVERY_SECONDS
|
|
17
|
+
|
|
18
|
+
# A cursor is a non-negative integer JSON carries exactly (spec Section
|
|
19
|
+
# 8.5.4, RFC 7493 Section 2.2).
|
|
20
|
+
MAX_CURSOR = (2**53) - 1
|
|
21
|
+
|
|
22
|
+
def subscribed
|
|
23
|
+
return reject unless connection.kiosk_credential_holds?
|
|
24
|
+
|
|
25
|
+
declaration = Kiosk::Server::Events.fetch(params[:topic].to_s)
|
|
26
|
+
return reject unless declaration && well_formed?
|
|
27
|
+
|
|
28
|
+
assign(declaration)
|
|
29
|
+
return reject unless reachable?(declaration)
|
|
30
|
+
|
|
31
|
+
open_stream!
|
|
32
|
+
transmit(subscribed_frame)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Action Cable confirms once the pubsub subscription is live, so replay
|
|
36
|
+
# starts here: an event emitted before that reaches the client this way.
|
|
37
|
+
def transmit_subscription_confirmation
|
|
38
|
+
super
|
|
39
|
+
replay!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def identity_key = connection.kiosk_identity_key
|
|
45
|
+
|
|
46
|
+
def identity = connection.kiosk_identity
|
|
47
|
+
|
|
48
|
+
def store = Kiosk.configuration.event_store
|
|
49
|
+
|
|
50
|
+
# `head` is the cursor the client records; `truncated` says events after
|
|
51
|
+
# its `since` were pruned, so it re-reads state once through the verb.
|
|
52
|
+
def subscribed_frame
|
|
53
|
+
{
|
|
54
|
+
'type' => 'subscribed',
|
|
55
|
+
'topic' => @topic,
|
|
56
|
+
'subject' => @subject,
|
|
57
|
+
'head' => @head,
|
|
58
|
+
'truncated' => since ? store.truncated?(identity_key, since) : false
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Everything after `since`, or after the head read before the stream
|
|
63
|
+
# opened. An event may arrive twice; delivery is at-least-once and the
|
|
64
|
+
# client ignores an `id` it has seen.
|
|
65
|
+
def replay!
|
|
66
|
+
store.since(identity_key, since || @head).each do |event|
|
|
67
|
+
transmit(event) if for_this_subscription?(event)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def since = params[:since]
|
|
72
|
+
|
|
73
|
+
def assign(declaration)
|
|
74
|
+
@topic = params[:topic].to_s
|
|
75
|
+
@subject = params[:subject]
|
|
76
|
+
@declaration = declaration
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def well_formed?
|
|
80
|
+
(since.nil? || cursor?(since)) && (params[:subject].nil? || params[:subject].is_a?(String))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def open_stream!
|
|
84
|
+
# Before the stream opens, so no event falls between head and stream.
|
|
85
|
+
@head = store.head
|
|
86
|
+
|
|
87
|
+
# Without `coder:` the block receives the raw broadcast String.
|
|
88
|
+
stream_from Kiosk::Server::EventsCable.stream_name(identity_key, @topic),
|
|
89
|
+
coder: ActiveSupport::JSON do |event|
|
|
90
|
+
transmit(event) if for_this_subscription?(event)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cursor?(value) = value.is_a?(Integer) && value.between?(0, MAX_CURSOR)
|
|
95
|
+
|
|
96
|
+
# Its own topic (the replayed tail holds every topic) and, when named, its
|
|
97
|
+
# own subject (a stream name carries none).
|
|
98
|
+
def for_this_subscription?(event)
|
|
99
|
+
return false unless event['topic'] == @topic
|
|
100
|
+
return true if @subject.nil?
|
|
101
|
+
|
|
102
|
+
event['subject'].to_s == @subject.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Spec Sections 8.5.4 and 8.5.6. No subject, or a `published` topic, is
|
|
106
|
+
# reachable; otherwise the topic's `subject_reachable` rule answers, and a
|
|
107
|
+
# topic without one admits a named subject only under `principal` reach.
|
|
108
|
+
def reachable?(declaration)
|
|
109
|
+
return true if @subject.nil? || declaration[:reach] == :published
|
|
110
|
+
|
|
111
|
+
callable = declaration[:subject_reachable]
|
|
112
|
+
return declaration[:reach] == :principal if callable.nil?
|
|
113
|
+
|
|
114
|
+
!!callable.call(@subject, identity)
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
log_rule_failure(e)
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Logged, because the refusal alone cannot tell a broken rule from a no.
|
|
121
|
+
def log_rule_failure(error)
|
|
122
|
+
logger&.error("[kiosk] events subject rule raised for topic #{@topic.inspect}: " \
|
|
123
|
+
"#{error.class}: #{error.message} at #{error.backtrace&.first}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Runs every REAUTHORISE_EVERY_SECONDS. A withdrawn reach is final for this
|
|
127
|
+
# subscription, so the declaration is dropped and the frame sent once.
|
|
128
|
+
def reauthorise!
|
|
129
|
+
return if @declaration.nil? || reachable?(@declaration)
|
|
130
|
+
|
|
131
|
+
@declaration = nil
|
|
132
|
+
# Braces required: bare pairs would be read as `transmit`'s keywords.
|
|
133
|
+
transmit({ 'type' => 'unsubscribed', 'topic' => @topic, 'reason' => 'reach_revoked' })
|
|
134
|
+
stop_all_streams
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
logger&.error("[kiosk] events re-authorisation failed: #{e.class}: #{e.message}")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# The base of the account holder's browser pages: a signed-in session, a
|
|
8
|
+
# CSRF token from the page's own form, and the binding module served.
|
|
9
|
+
class ApplicationController < ::ActionController::Base
|
|
10
|
+
include AccountHolderGate
|
|
11
|
+
include BindingModuleGate
|
|
12
|
+
include WrongDoor
|
|
13
|
+
|
|
14
|
+
prepend_before_action :refuse_unserved_binding
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# «Link an assistant» page: a signed-in account holder lists, labels,
|
|
6
|
+
# links and unlinks their assistants. Host views override
|
|
7
|
+
# app/views/kiosk/server/assistants/show.html.erb.
|
|
8
|
+
class AssistantsController < ApplicationController
|
|
9
|
+
SIGN_IN_PROMPT = 'Sign in to your account first to manage linked assistants.'
|
|
10
|
+
SIGN_IN_ALERT = 'Please sign in to manage your linked assistants.'
|
|
11
|
+
|
|
12
|
+
WRONG_DOOR_MESSAGE = "this is the account holder's browser page, not the Kiosk wire — " \
|
|
13
|
+
'it needs a signed-in session and a CSRF token from its own form'
|
|
14
|
+
|
|
15
|
+
layout false
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
19
|
+
|
|
20
|
+
render_page
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The signed-in human's role goes on the link row, as on the JSON path;
|
|
24
|
+
# `bind!` validates it at redeem.
|
|
25
|
+
def link
|
|
26
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
27
|
+
|
|
28
|
+
result = LinkCode.mint(user_id: @identity.user_id, requested_role: @identity.role)
|
|
29
|
+
@link_code = result[:link_code]
|
|
30
|
+
@expires_in = result[:expires_in]
|
|
31
|
+
render_page
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unlink
|
|
35
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
36
|
+
|
|
37
|
+
AccountBinding.unlink!(agent_id: params[:agent_id].to_s, user_id: @identity.user_id)
|
|
38
|
+
@notice = 'Assistant unlinked — its key no longer signs in.'
|
|
39
|
+
render_page
|
|
40
|
+
rescue Errors::Base => e
|
|
41
|
+
render_error(e)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Edits a bound assistant's label and/or spending cap and its currency (blank cap → unlimited).
|
|
45
|
+
def update
|
|
46
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
47
|
+
|
|
48
|
+
changes = settings_changes
|
|
49
|
+
LinkedAssistants.save_settings(params[:agent_id].to_s, @identity.user_id, changes) if changes.any?
|
|
50
|
+
@notice = 'Assistant settings saved.'
|
|
51
|
+
render_page
|
|
52
|
+
rescue Errors::Base => e
|
|
53
|
+
render_error(e)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def render_error(error)
|
|
59
|
+
@error = error.message
|
|
60
|
+
render_page(status: error.http_status)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_page(status: :ok)
|
|
64
|
+
@assistants = LinkedAssistants.list(@identity.user_id)
|
|
65
|
+
# Without a `config.spending_cap` seam the saved cap binds nothing; the page says so.
|
|
66
|
+
@spending_cap_enforced = !Kiosk.configuration.spending_cap.nil?
|
|
67
|
+
@page_path = request.path.sub(%r{/(link|unlink|update)\z}, '')
|
|
68
|
+
render :show, status: status
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Column name → new value; nil writes NULL.
|
|
72
|
+
def settings_changes
|
|
73
|
+
changes = {}
|
|
74
|
+
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?(:spending_cap_cents)
|
|
76
|
+
changes
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# `[cents, currency]`, or `[nil, nil]` for no cap.
|
|
80
|
+
def spending_cap_param
|
|
81
|
+
raw = params[:spending_cap_cents].to_s
|
|
82
|
+
return [nil, nil] if raw.strip.empty?
|
|
83
|
+
|
|
84
|
+
cents = Integer(raw, exception: false) || raise(Errors::BadRequest, 'spending_cap_cents must be an integer')
|
|
85
|
+
[cents, spending_cap_currency_param]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def spending_cap_currency_param
|
|
89
|
+
code = MandateVerifier.canonical_currency(params[:spending_cap_currency])
|
|
90
|
+
return code if MandateVerifier::CURRENCY_CODE.match?(code)
|
|
91
|
+
|
|
92
|
+
raise Errors::BadRequest, 'spending_cap_currency must be a three-letter ISO 4217 code, such as EUR'
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# The spec's challenge-response auth paths (§6), drawn by the engine: challenge, register, login,
|
|
9
|
+
# revoke, and the account-binding link, claim and unlink.
|
|
10
|
+
class AuthController < ::ActionController::API
|
|
11
|
+
# The JSON answers and RFC 9457 problem documents of the auth paths.
|
|
12
|
+
module Answers
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def respond(payload, status)
|
|
16
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
17
|
+
render json: payload, status: status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The same RFC 9457 problem document as the wire (§9).
|
|
21
|
+
def render_error(err)
|
|
22
|
+
add_problem_headers(err)
|
|
23
|
+
render json: err.to_problem, status: err.http_status,
|
|
24
|
+
content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_problem_headers(err)
|
|
28
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
29
|
+
Kiosk::Server::Headers.add_cache_policy(response.headers, status: err.http_status)
|
|
30
|
+
err.response_headers.each { |name, value| response.set_header(name, value) }
|
|
31
|
+
challenge = www_authenticate_for(err)
|
|
32
|
+
response.set_header('WWW-Authenticate', challenge) if challenge
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# RFC 7235 challenge header for the 402s, as on the wire.
|
|
36
|
+
def www_authenticate_for(err)
|
|
37
|
+
issuer = Kiosk.current_issuer
|
|
38
|
+
case err
|
|
39
|
+
when Errors::PowRequired then %(Kiosk-PoW realm="#{issuer}")
|
|
40
|
+
when Errors::PaymentSetupRequired then %(Payment realm="#{issuer}", method="ap2")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
include Answers
|
|
45
|
+
|
|
46
|
+
# The auth toll's proofs: the `Kiosk-PoW` header, schema-checked, or nil.
|
|
47
|
+
module Proofs
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def proofs
|
|
51
|
+
header = request.get_header('HTTP_KIOSK_POW')
|
|
52
|
+
pow = PowGate.proofs_from_header(header)
|
|
53
|
+
RequestValidation.validate_proofs!(pow) if Kiosk.configuration.validate_requests && pow
|
|
54
|
+
pow
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
include Proofs
|
|
58
|
+
|
|
59
|
+
# Only the binding actions depend on this origin serving binding.
|
|
60
|
+
include BindingModuleGate
|
|
61
|
+
|
|
62
|
+
prepend_before_action :refuse_unserved_binding, only: %i[link claim unlink]
|
|
63
|
+
|
|
64
|
+
# Unauthenticated: the nonce is worthless without the matching private key.
|
|
65
|
+
def challenge
|
|
66
|
+
public_key = request.query_parameters['public_key']
|
|
67
|
+
raise Errors::BadRequest, 'missing public_key query parameter' if public_key.blank?
|
|
68
|
+
|
|
69
|
+
respond(AuthChallenge.issue(public_key_pem: public_key), :ok)
|
|
70
|
+
rescue Errors::Base => e
|
|
71
|
+
render_error(e)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Registers a new public key (409 if known). The optional PoW proof rides in the `Kiosk-PoW`
|
|
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)
|
|
81
|
+
rescue KeyError => e
|
|
82
|
+
render_error(Errors.missing_field(e))
|
|
83
|
+
rescue Errors::Base => e
|
|
84
|
+
render_error(e)
|
|
85
|
+
end
|
|
86
|
+
|
|
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
|
+
# Mints a link code for the signed-in holder; the holder's `user_idp` role becomes the bound assistant's.
|
|
111
|
+
def link
|
|
112
|
+
identity = authenticated_account_holder!
|
|
113
|
+
result = LinkCode.mint(user_id: identity.user_id, requested_role: identity.role)
|
|
114
|
+
respond({ link_code: result[:link_code], expires_in: result[:expires_in] }, :created)
|
|
115
|
+
rescue Errors::Base => e
|
|
116
|
+
render_error(e)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# §6.2's three fields only: `fresh` stays off the wire so a re-bind is indistinguishable (§6.3).
|
|
120
|
+
CLAIM_RESPONSE_FIELDS = %i[agent_id user_id access_token].freeze
|
|
121
|
+
|
|
122
|
+
# The optional PoW proof rides in the `Kiosk-PoW` header, as on register.
|
|
123
|
+
def claim
|
|
124
|
+
body = parse_body!('POST <endpoint>/auth/claim')
|
|
125
|
+
result = LinkCode.redeem(code: body.fetch(:code), public_key_pem: body.fetch(:public_key),
|
|
126
|
+
signed: body.fetch(:signed), pow: proofs)
|
|
127
|
+
respond(result.slice(*CLAIM_RESPONSE_FIELDS), :created)
|
|
128
|
+
rescue KeyError => e
|
|
129
|
+
render_error(Errors.missing_field(e))
|
|
130
|
+
rescue Errors::Base => e
|
|
131
|
+
render_error(e)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Deactivates one of the signed-in holder's assistant accounts; answers 204 with no body (§6.2).
|
|
135
|
+
def unlink
|
|
136
|
+
identity = authenticated_account_holder!
|
|
137
|
+
body = parse_body!('POST <endpoint>/auth/unlink')
|
|
138
|
+
AccountBinding.unlink!(agent_id: body.fetch(:agent_id), user_id: identity.user_id)
|
|
139
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
140
|
+
head :no_content
|
|
141
|
+
rescue KeyError => e
|
|
142
|
+
render_error(Errors.missing_field(e))
|
|
143
|
+
rescue Errors::Base => e
|
|
144
|
+
render_error(e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
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
|
+
# The human's side of binding authenticates by the provider's session, never by an agent token.
|
|
155
|
+
# A page on another origin can post a form with that cookie, but cannot send JSON without a CORS preflight.
|
|
156
|
+
def authenticated_account_holder!
|
|
157
|
+
identity = Kiosk.configuration.user_idp&.verify(request)
|
|
158
|
+
hint = "sign in to the provider first — this endpoint authenticates via the provider's own session"
|
|
159
|
+
raise Errors::Unauthenticated.new('account session required', hint:) unless identity
|
|
160
|
+
raise Errors::BadRequest, 'Content-Type must be application/json' unless request.media_type == 'application/json'
|
|
161
|
+
|
|
162
|
+
identity
|
|
163
|
+
end
|
|
164
|
+
|
|
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
|
+
# Schema-checked before any `fetch`, so a wrong-typed member is a 400 naming it (§17).
|
|
173
|
+
def parse_body!(exchange)
|
|
174
|
+
raw = request.raw_post
|
|
175
|
+
raise Errors::BadRequest, 'request body must be a JSON object' if raw.blank?
|
|
176
|
+
|
|
177
|
+
parsed = JSON.parse(raw, symbolize_names: true)
|
|
178
|
+
raise Errors::BadRequest, 'request body must be a JSON object' unless parsed.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
RequestValidation.validate_body!(parsed, exchange: exchange)
|
|
181
|
+
parsed
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
raise Errors.malformed_json
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The verify page where a signed-in human approves or denies an assistant
|
|
6
|
+
# link. The role the panel shows is the role the approval stamps: the
|
|
7
|
+
# human's own, never one the assistant asked for.
|
|
8
|
+
class DeviceVerifyController < ApplicationController
|
|
9
|
+
SIGN_IN_PROMPT = 'Sign in to your account first, then re-open this page to approve the assistant link.'
|
|
10
|
+
SIGN_IN_ALERT = 'Please sign in to approve the assistant link.'
|
|
11
|
+
|
|
12
|
+
CODE_NOT_RECOGNISED = 'That code was not recognised — it may have expired. Ask the assistant for a fresh one.'
|
|
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
|
+
# Failed code lookups per session before a 429, against 31^8 possible codes.
|
|
18
|
+
MAX_CODE_ATTEMPTS = 10
|
|
19
|
+
|
|
20
|
+
layout false
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
24
|
+
return render_attempts_capped if attempts_capped?
|
|
25
|
+
|
|
26
|
+
@user_code = params[:user_code].to_s
|
|
27
|
+
@authorization = find_authorization(@user_code)
|
|
28
|
+
@fingerprint = key_fingerprint(@authorization)
|
|
29
|
+
@role = @identity.role
|
|
30
|
+
render :show
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create
|
|
34
|
+
return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
|
|
35
|
+
return render_attempts_capped if attempts_capped?
|
|
36
|
+
|
|
37
|
+
@user_code = params[:user_code].to_s
|
|
38
|
+
@role = @identity.role
|
|
39
|
+
@decision = decide(params[:decision].to_s)
|
|
40
|
+
return render plain: 'decision must be approve or deny', status: :bad_request unless @decision
|
|
41
|
+
|
|
42
|
+
render :decided
|
|
43
|
+
rescue DeviceVerification::CodeNotFoundError
|
|
44
|
+
render_code_not_recognised
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def find_authorization(user_code)
|
|
50
|
+
return nil if user_code.empty?
|
|
51
|
+
|
|
52
|
+
authorization = DeviceVerification.find_pending(user_code: user_code)
|
|
53
|
+
if authorization.nil?
|
|
54
|
+
record_failed_attempt
|
|
55
|
+
@error = CODE_NOT_RECOGNISED
|
|
56
|
+
end
|
|
57
|
+
authorization
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_code_not_recognised
|
|
61
|
+
record_failed_attempt
|
|
62
|
+
@authorization = nil
|
|
63
|
+
@error = CODE_NOT_RECOGNISED
|
|
64
|
+
render :show, status: :unprocessable_content
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def decide(decision)
|
|
68
|
+
case decision
|
|
69
|
+
when 'approve'
|
|
70
|
+
DeviceVerification.approve(user_code: @user_code, user_id: @identity.user_id, role: @identity.role)
|
|
71
|
+
:approved
|
|
72
|
+
when 'deny'
|
|
73
|
+
DeviceVerification.deny(user_code: @user_code)
|
|
74
|
+
:denied
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def record_failed_attempt
|
|
79
|
+
session[:kiosk_verify_attempts] = session[:kiosk_verify_attempts].to_i + 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attempts_capped?
|
|
83
|
+
session[:kiosk_verify_attempts].to_i >= MAX_CODE_ATTEMPTS
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_attempts_capped
|
|
87
|
+
render plain: 'Too many code attempts — sign out and back in to retry.',
|
|
88
|
+
status: :too_many_requests
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The key's RFC 7638 thumbprint, the `kid` the assistant prints.
|
|
92
|
+
def key_fingerprint(authorization)
|
|
93
|
+
return nil if authorization&.public_key_pem.nil?
|
|
94
|
+
|
|
95
|
+
SigningKey.from_pem(authorization.public_key_pem).kid
|
|
96
|
+
rescue StandardError
|
|
97
|
+
'(unreadable key)'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# The public discovery documents at the host root, all rendered by
|
|
8
|
+
# {WellKnown} for `request.base_url`.
|
|
9
|
+
class DiscoveryController < ::ActionController::API
|
|
10
|
+
# Rails stamps `Vary: Accept` on a negotiated render; these documents are
|
|
11
|
+
# one answer for everybody, so a shared cache must keep one copy.
|
|
12
|
+
after_action :drop_vary
|
|
13
|
+
|
|
14
|
+
def agents_txt
|
|
15
|
+
allow_cors
|
|
16
|
+
render plain: WellKnown.agents_txt(base_url: request.base_url),
|
|
17
|
+
content_type: 'text/plain; charset=utf-8'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def agents_json
|
|
21
|
+
allow_cors
|
|
22
|
+
short_ttl
|
|
23
|
+
render json: WellKnown.agents_json(base_url: request.base_url)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def agent_configuration
|
|
27
|
+
render json: WellKnown.agent_configuration(base_url: request.base_url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def kiosk_json
|
|
31
|
+
short_ttl
|
|
32
|
+
render json: WellKnown.build_json(base_url: request.base_url)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def api_catalog
|
|
36
|
+
allow_cors
|
|
37
|
+
short_ttl
|
|
38
|
+
render json: WellKnown.api_catalog(base_url: request.base_url),
|
|
39
|
+
content_type: 'application/linkset+json; ' \
|
|
40
|
+
'profile="https://www.rfc-editor.org/info/rfc9727"'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def auth_md
|
|
44
|
+
allow_cors
|
|
45
|
+
render plain: WellKnown.auth_md(base_url: request.base_url),
|
|
46
|
+
content_type: 'text/markdown; charset=utf-8'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# agents.txt v1.0 requires `Access-Control-Allow-Origin: *`.
|
|
52
|
+
def allow_cors
|
|
53
|
+
response.set_header('Access-Control-Allow-Origin', '*')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# These carry the `?v=<digest>` schema link, so they must expire soon after a deploy.
|
|
57
|
+
def short_ttl
|
|
58
|
+
response.set_header('Cache-Control', Headers::PUBLIC_SHORT)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drop_vary
|
|
62
|
+
response.headers.delete('Vary')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# GET <endpoint>/.well-known/jwks.json — the public signing key.
|
|
8
|
+
class JwksController < ::ActionController::API
|
|
9
|
+
def show
|
|
10
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
11
|
+
render json: Kiosk::Server::Jwks.build(keys: [Kiosk.configuration.signing_key])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# POST <endpoint>/agents/kyc `{kyc_jws}`: verifies an assistant-submitted
|
|
9
|
+
# attestation and replaces its principal's grants ({Kyc.grant!}). Refusals
|
|
10
|
+
# are problem documents: 400 body, 401 token, 403 attestation.
|
|
11
|
+
class KycAttestationController < ::ActionController::API
|
|
12
|
+
def create
|
|
13
|
+
identity = authenticate!
|
|
14
|
+
body = parse_body!('POST <endpoint>/agents/kyc')
|
|
15
|
+
raw_jws = body[:kyc_jws] or raise Errors.missing_field('kyc_jws')
|
|
16
|
+
|
|
17
|
+
render_granted(grant!(identity, raw_jws))
|
|
18
|
+
rescue Errors::Base => e
|
|
19
|
+
render_error(e)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Verifies the attestation for this principal and stores its grants.
|
|
25
|
+
def grant!(identity, raw_jws)
|
|
26
|
+
attributes = KycVerifier.verify(raw_jws: raw_jws, subject: identity.user_id)[:attributes]
|
|
27
|
+
Kyc.grant!(identity.user_id, attributes)
|
|
28
|
+
attributes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_granted(attributes)
|
|
32
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
33
|
+
render json: { kyc_verified: true, attributes: attributes }, status: :ok
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A JSON object held to the shape §17 publishes for `exchange`, else 400.
|
|
37
|
+
def parse_body!(exchange)
|
|
38
|
+
raw = request.raw_post
|
|
39
|
+
raise Errors::BadRequest, 'request body must be a JSON object' if raw.blank?
|
|
40
|
+
|
|
41
|
+
parsed = JSON.parse(raw, symbolize_names: true)
|
|
42
|
+
raise Errors::BadRequest, 'request body must be a JSON object' unless parsed.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
RequestValidation.validate_body!(parsed, exchange: exchange)
|
|
45
|
+
parsed
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
raise Errors.malformed_json
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Only an assistant submits an attestation, so only the agent IdP answers.
|
|
51
|
+
def authenticate!
|
|
52
|
+
identity = IdentityResolution.agent_idp.verify(request)
|
|
53
|
+
raise Errors::Unauthenticated, 'missing or invalid agent token' if identity.nil?
|
|
54
|
+
|
|
55
|
+
identity
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_error(err)
|
|
59
|
+
Kiosk::Server::Headers.add_to(response.headers)
|
|
60
|
+
Kiosk::Server::Headers.add_cache_policy(
|
|
61
|
+
response.headers, status: err.http_status
|
|
62
|
+
)
|
|
63
|
+
err.response_headers.each { |name, value| response.set_header(name, value) }
|
|
64
|
+
render json: err.to_problem, status: err.http_status,
|
|
65
|
+
content_type: Errors::PROBLEM_CONTENT_TYPE
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|