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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# One action invocation, as `Kiosk.configuration.audit_sink` receives it.
|
|
6
|
+
# {#args} arrive unredacted: whoever stores this event controls that data.
|
|
7
|
+
# {#cause_class}/{#cause_message} carry the handler's own error under a Kiosk wrapper.
|
|
8
|
+
class ActionEvent < Data.define(:action, :user_id, :agent_id, :role, :actor, :args,
|
|
9
|
+
:status, :error_class, :error_message,
|
|
10
|
+
:cause_class, :cause_message, :invoked_at)
|
|
11
|
+
OK = 'ok'
|
|
12
|
+
ERROR = 'error'
|
|
13
|
+
|
|
14
|
+
def self.build(identity:, name:, args:, status:, error: nil, invoked_at: Time.current) # rubocop:disable Metrics/ParameterLists -- the public keyword API that the executor and audit sinks call
|
|
15
|
+
new(action: name.to_s, **principal(identity), args: args.is_a?(Hash) ? args : {},
|
|
16
|
+
status: status.to_s, **failure(error), invoked_at: invoked_at)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.principal(identity)
|
|
20
|
+
{ user_id: identity.user_id, agent_id: identity.agent_id, role: identity.role, actor: identity.actor }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The error and its cause as class names and messages; all nil without an error.
|
|
24
|
+
def self.failure(error)
|
|
25
|
+
cause = cause_of(error)
|
|
26
|
+
{ error_class: error&.class&.name, error_message: error && error.message.to_s,
|
|
27
|
+
cause_class: cause&.class&.name, cause_message: cause && cause.message.to_s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An exception re-raised inside its own `rescue` can be its own cause.
|
|
31
|
+
def self.cause_of(error)
|
|
32
|
+
return nil unless error.is_a?(::Exception)
|
|
33
|
+
|
|
34
|
+
cause = error.cause
|
|
35
|
+
cause.is_a?(::Exception) && !cause.equal?(error) ? cause : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `{"salon_id" => "integer", "slot" => "string"}`, in JSON Schema type names.
|
|
39
|
+
def arg_types
|
|
40
|
+
args.to_h { |key, value| [key.to_s, self.class.json_type(value)] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_arg_types = with(args: arg_types)
|
|
44
|
+
|
|
45
|
+
def without_args = with(args: {})
|
|
46
|
+
|
|
47
|
+
def self.json_type(value)
|
|
48
|
+
case value
|
|
49
|
+
when nil then 'null'
|
|
50
|
+
when true, false then 'boolean'
|
|
51
|
+
when Integer then 'integer'
|
|
52
|
+
when Float, Numeric then 'number'
|
|
53
|
+
when Array then 'array'
|
|
54
|
+
when Hash then 'object'
|
|
55
|
+
else 'string'
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Registry of actions (`kind :action`): verbs that change something the
|
|
6
|
+
# operator owns. The behaviour is {VerbRegistry}.
|
|
7
|
+
module Actions
|
|
8
|
+
SCOPE = :action
|
|
9
|
+
Entry = VerbRegistry::Entry
|
|
10
|
+
|
|
11
|
+
extend VerbRegistry
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kiosk/agent_identity_providers/base'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
module AgentIdentityProviders
|
|
8
|
+
# The bundled agent IdP: RS256 tokens signed with the origin's own key;
|
|
9
|
+
# payment keys from the agents table.
|
|
10
|
+
class DefaultAgentIdp < Kiosk::AgentIdentityProviders::Base
|
|
11
|
+
def verify(request)
|
|
12
|
+
header = authorization_for(request)
|
|
13
|
+
return nil if header.blank?
|
|
14
|
+
|
|
15
|
+
claims = verified_claims(header.sub(/\ABearer\s+/i, ''))
|
|
16
|
+
Kiosk::Identity.new(
|
|
17
|
+
user_id: claims[:sub], role: claims[:role], actor: 'agent',
|
|
18
|
+
agent_id: claims[:agent_id], claims: claims
|
|
19
|
+
)
|
|
20
|
+
rescue JwtIssuer::Error
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def issue(agent_id:, role:)
|
|
25
|
+
claims = { sub: lookup_user_id(agent_id), agent_id: agent_id, actor: 'agent' }
|
|
26
|
+
claims[:role] = role.to_s unless role.nil? || role.to_s.empty?
|
|
27
|
+
JwtIssuer.issue(
|
|
28
|
+
claims: claims, audience: Kiosk.current_issuer, now: mint_instant(agent_id),
|
|
29
|
+
expires_in: Kiosk.configuration.access_token_ttl
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def agent_payment_key(agent_id)
|
|
34
|
+
pem = agents_column('public_key', agent_id)&.fetch('public_key', nil)
|
|
35
|
+
raise Kiosk::AgentIdentityProviders::InvalidToken, "no key for agent #{agent_id}" if pem.nil?
|
|
36
|
+
|
|
37
|
+
OpenSSL::PKey::RSA.new(pem)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def verified_claims(token)
|
|
43
|
+
JwtIssuer.verify(
|
|
44
|
+
token: token,
|
|
45
|
+
jwks: Jwks.build(keys: [Kiosk.configuration.signing_key]),
|
|
46
|
+
audience: Kiosk.current_issuer,
|
|
47
|
+
issuer: Kiosk.current_issuer
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Never earlier than the revocation watermark, so a token minted in the
|
|
52
|
+
# second a rebind revoked (§6.3) still survives `iat < watermark`.
|
|
53
|
+
def mint_instant(agent_id)
|
|
54
|
+
now = Time.current
|
|
55
|
+
watermark = Kiosk.configuration.revocation_store.watermark_for(agent_id)
|
|
56
|
+
return now if watermark.nil? || watermark <= now.to_i
|
|
57
|
+
|
|
58
|
+
Time.at(watermark).utc
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lookup_user_id(agent_id)
|
|
62
|
+
row = agents_column('user_id', agent_id)
|
|
63
|
+
raise Kiosk::AgentIdentityProviders::InvalidToken, "unknown agent #{agent_id}" if row.nil?
|
|
64
|
+
|
|
65
|
+
row.fetch('user_id')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `column` is always a literal from this class, never caller input.
|
|
69
|
+
def agents_column(column, agent_id)
|
|
70
|
+
::ActiveRecord::Base.lease_connection.exec_query(
|
|
71
|
+
"SELECT #{column} FROM #{schema}.agents WHERE id = $1 AND revoked_at IS NULL",
|
|
72
|
+
"Kiosk agent #{column}",
|
|
73
|
+
[agent_id]
|
|
74
|
+
).to_a.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def schema = Kiosk.configuration.schema
|
|
78
|
+
|
|
79
|
+
def authorization_for(request)
|
|
80
|
+
if request.respond_to?(:headers)
|
|
81
|
+
request.headers['Authorization'] || request.headers['authorization']
|
|
82
|
+
elsif request.is_a?(Hash)
|
|
83
|
+
request['HTTP_AUTHORIZATION']
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Login with an already-registered public key: proves possession and mints a
|
|
6
|
+
# fresh token. An unknown key is a 404, never a new account.
|
|
7
|
+
module AgentLogin
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(public_key_pem:, signed:)
|
|
11
|
+
pem = public_key_pem.to_s.strip
|
|
12
|
+
|
|
13
|
+
# Prove possession BEFORE any lookup or state change.
|
|
14
|
+
payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
|
|
15
|
+
AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
|
|
16
|
+
|
|
17
|
+
row = registered_agent(pem)
|
|
18
|
+
agent_id = row.fetch('id')
|
|
19
|
+
role = primary_role(row.fetch('allowed_roles'))
|
|
20
|
+
token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
|
|
21
|
+
{ access_token: token }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def registered_agent(pem)
|
|
25
|
+
row = ::ActiveRecord::Base.lease_connection.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [pem, Kiosk.current_issuer]).to_a.first
|
|
26
|
+
SELECT id, user_id, allowed_roles FROM #{Kiosk.configuration.schema}.agents
|
|
27
|
+
WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
28
|
+
LIMIT 1
|
|
29
|
+
SQL
|
|
30
|
+
return row if row
|
|
31
|
+
|
|
32
|
+
raise Errors::NotFound.new(
|
|
33
|
+
'no agent registered for this public key',
|
|
34
|
+
hint: 'POST /auth/register to create an identity for a new key'
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
private_class_method :registered_agent
|
|
38
|
+
|
|
39
|
+
# `allowed_roles` is an Array or a text[] literal ("{customer}"), by adapter.
|
|
40
|
+
def primary_role(allowed_roles)
|
|
41
|
+
case allowed_roles
|
|
42
|
+
when Array then allowed_roles.first
|
|
43
|
+
else allowed_roles.to_s.delete('{}').split(',').first
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :primary_role
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'active_support/core_ext/string/inflections' # String#constantize
|
|
5
|
+
|
|
6
|
+
module Kiosk
|
|
7
|
+
module Server
|
|
8
|
+
# Self-registration with no human: creates the assistant account, binds an
|
|
9
|
+
# agent to the key, and mints its first token.
|
|
10
|
+
module AgentRegistration
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(public_key_pem:, signed:, pow: nil)
|
|
14
|
+
config = Kiosk.configuration
|
|
15
|
+
role = registration_role(config)
|
|
16
|
+
public_key_pem = public_key_pem.to_s.strip
|
|
17
|
+
|
|
18
|
+
AuthToll.gate(verb: 'auth/register', body: { public_key: public_key_pem }, pow: pow, config: config)
|
|
19
|
+
|
|
20
|
+
# Prove possession of the private key before any lookup or write.
|
|
21
|
+
payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
|
|
22
|
+
AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
|
|
23
|
+
|
|
24
|
+
conn = ::ActiveRecord::Base.lease_connection
|
|
25
|
+
refuse_known_key!(conn, config, public_key_pem)
|
|
26
|
+
conn.transaction { register(conn, config, public_key_pem, role) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The operator's `assistant_creation` factory, else a bare `user_model.create!`.
|
|
30
|
+
def create_assistant_account(config, public_key_pem)
|
|
31
|
+
return config.user_model.constantize.create!.id unless config.assistant_creation
|
|
32
|
+
|
|
33
|
+
id = config.assistant_creation.call(public_key_pem)
|
|
34
|
+
return id unless id.nil?
|
|
35
|
+
|
|
36
|
+
raise Errors::ConfigurationError,
|
|
37
|
+
'config.assistant_creation returned nil. The block must create the ' \
|
|
38
|
+
'assistant account and RETURN its id (used as agents.user_id), e.g. ' \
|
|
39
|
+
'c.assistant_creation = ->(pubkey) { AssistantAccount.create!(...).id }'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The role is the server's choice, never the agent's.
|
|
43
|
+
def registration_role(config)
|
|
44
|
+
role = config.registration_role&.to_s
|
|
45
|
+
return if role.blank?
|
|
46
|
+
return role if config.roles.map(&:to_s).include?(role)
|
|
47
|
+
|
|
48
|
+
raise Errors::ConfigurationError,
|
|
49
|
+
"registration_role #{role.inspect} is not among configured roles " \
|
|
50
|
+
"#{config.roles.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A known key logs in instead; it is never registered twice.
|
|
54
|
+
def refuse_known_key!(conn, config, public_key_pem)
|
|
55
|
+
existing = conn.exec_query(<<~SQL.squish, 'Kiosk agent lookup by key', [public_key_pem, Kiosk.current_issuer]).to_a.first
|
|
56
|
+
SELECT id FROM #{config.schema}.agents
|
|
57
|
+
WHERE public_key = $1 AND issuer = $2 AND revoked_at IS NULL
|
|
58
|
+
LIMIT 1
|
|
59
|
+
SQL
|
|
60
|
+
return unless existing
|
|
61
|
+
|
|
62
|
+
raise Errors::Conflict.new(
|
|
63
|
+
'public key already registered',
|
|
64
|
+
hint: 'use POST /auth/login to refresh a token for an existing key'
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register(conn, config, public_key_pem, role)
|
|
69
|
+
assistant_account_id = create_assistant_account(config, public_key_pem)
|
|
70
|
+
agent_id = insert_agent(conn, config, [assistant_account_id, public_key_pem, Kiosk.current_issuer], role)
|
|
71
|
+
token = AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: agent_id, role: role)
|
|
72
|
+
{ agent_id: agent_id, user_id: assistant_account_id.to_s, access_token: token }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# No role is the empty array: `allowed_roles` is NOT NULL.
|
|
76
|
+
def insert_agent(conn, config, binds, role)
|
|
77
|
+
allowed_roles_sql, role_binds = role ? ['ARRAY[$4]::text[]', [role]] : ["'{}'::text[]", []]
|
|
78
|
+
rows = conn.exec_query(<<~SQL.squish, 'Kiosk agent insert', [*binds, *role_binds])
|
|
79
|
+
INSERT INTO #{config.schema}.agents (user_id, allowed_roles, public_key, issuer)
|
|
80
|
+
VALUES ($1, #{allowed_roles_sql}, $2, $3)
|
|
81
|
+
RETURNING id
|
|
82
|
+
SQL
|
|
83
|
+
rows.to_a.first.fetch('id')
|
|
84
|
+
end
|
|
85
|
+
private_class_method :registration_role, :refuse_known_key!, :register, :insert_agent
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'rack'
|
|
6
|
+
|
|
7
|
+
module Kiosk
|
|
8
|
+
module Server
|
|
9
|
+
# Decodes a query's arguments from the URL query string and coerces them
|
|
10
|
+
# to the types `input_schema` declares. Validation runs afterwards, on the
|
|
11
|
+
# coerced result (RequestValidation).
|
|
12
|
+
module ArgumentDecoder
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Reserved names and their types. A verb never declares `cursor`; it may declare `limit` (§8.1 item 6).
|
|
16
|
+
RESERVED = { 'limit' => 'integer', 'cursor' => 'string' }.freeze
|
|
17
|
+
|
|
18
|
+
# @param input_schema [Hash, nil] the verb's `input_schema`, symbol- or string-keyed
|
|
19
|
+
# @raise [Errors::BadRequest] naming the parameter
|
|
20
|
+
def decode(query_string, input_schema: nil)
|
|
21
|
+
raw = QueryShape.parse!(query_string)
|
|
22
|
+
raw = QueryShape.fold_declared_arrays(raw, query_string, input_schema)
|
|
23
|
+
QueryShape.reject_undecodable_shapes!(raw)
|
|
24
|
+
coerce_all(raw, input_schema)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def coerce_all(raw, input_schema)
|
|
28
|
+
raw.each_with_object({}) do |(name, value), out|
|
|
29
|
+
out[name.to_sym] = Coercion.coerce(value, property_for(name, input_schema), name.to_s)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An undeclared parameter stays a String; whether it is allowed is the validator's call.
|
|
34
|
+
def property_for(name, input_schema)
|
|
35
|
+
declared = fetch(fetch(input_schema, :properties), name)
|
|
36
|
+
return declared unless declared.nil?
|
|
37
|
+
|
|
38
|
+
reserved = RESERVED[name.to_s]
|
|
39
|
+
reserved && { 'type' => reserved }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch(hash, key)
|
|
43
|
+
return nil unless hash.is_a?(::Hash)
|
|
44
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
|
45
|
+
|
|
46
|
+
hash[key.to_s]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A nullable union like `["integer", "null"]` coerces to its non-null member.
|
|
50
|
+
def declared_type(property)
|
|
51
|
+
type = fetch(property, :type)
|
|
52
|
+
case type
|
|
53
|
+
when ::Array then type.map(&:to_s).reject { |t| t == 'null' }.first
|
|
54
|
+
when nil then nil
|
|
55
|
+
else type.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# What a caller has to send for each string `format` the wire reads.
|
|
60
|
+
FORMAT_SPELLINGS = {
|
|
61
|
+
'date' => 'a calendar date as YYYY-MM-DD, e.g. 2026-08-19',
|
|
62
|
+
'date-time' => 'an ISO 8601 timestamp, e.g. 2026-08-19T14:00:00Z'
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
SHAPE_HINT =
|
|
66
|
+
'query arguments are scalars (`a=v`), arrays of scalars (repeated ' \
|
|
67
|
+
'`a%5B%5D=v`) or one level of object with scalar leaves ' \
|
|
68
|
+
'(`o%5Bk%5D=v`); a name is one shape or the other, never both.'
|
|
69
|
+
|
|
70
|
+
DEPTH_HINT =
|
|
71
|
+
"a query's arguments are one level deep. A read whose input needs an " \
|
|
72
|
+
'array of objects or two levels of nesting is an ACTION — POST it to ' \
|
|
73
|
+
'<endpoint>/<action-name> with a JSON body.'
|
|
74
|
+
|
|
75
|
+
NARROWING_HINT =
|
|
76
|
+
"an object argument's leaves are SCALARS: `o%5Bk%5D=v`, not " \
|
|
77
|
+
'`o%5Bk%5D%5B%5D=v`. Anything richer is an ACTION (POST).'
|
|
78
|
+
|
|
79
|
+
# The query string's shape: what Rack can decode and the wire accepts.
|
|
80
|
+
module QueryShape
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
def parse!(query_string)
|
|
84
|
+
Rack::Utils.parse_nested_query(query_string.to_s)
|
|
85
|
+
rescue ::Rack::BadRequest
|
|
86
|
+
raise Errors::BadRequest.new(
|
|
87
|
+
'the query string could not be decoded',
|
|
88
|
+
hint: SHAPE_HINT
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A bare repeated `a=1&a=2` becomes an array only when the verb declares
|
|
93
|
+
# `type: "array"`; otherwise Rack's last value wins.
|
|
94
|
+
def fold_declared_arrays(raw, query_string, input_schema)
|
|
95
|
+
flat = nil
|
|
96
|
+
raw.each_with_object({}) do |(name, value), out|
|
|
97
|
+
unless value.is_a?(::String) && declared_array?(name, input_schema)
|
|
98
|
+
out[name] = value
|
|
99
|
+
next
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
flat ||= ::Rack::Utils.parse_query(query_string.to_s)
|
|
103
|
+
repeated = flat[name]
|
|
104
|
+
out[name] = repeated.nil? ? [value] : Array(repeated)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def declared_array?(name, input_schema)
|
|
109
|
+
ArgumentDecoder.declared_type(ArgumentDecoder.property_for(name, input_schema)) == 'array'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def reject_undecodable_shapes!(raw)
|
|
113
|
+
raw.each do |name, value|
|
|
114
|
+
case value
|
|
115
|
+
when ::Array then reject_nonscalar_elements!(name, value)
|
|
116
|
+
when ::Hash then reject_nonscalar_leaves!(name, value)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
raw
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reject_nonscalar_elements!(name, value)
|
|
123
|
+
value.each do |element|
|
|
124
|
+
next if element.nil? || element.is_a?(::String)
|
|
125
|
+
|
|
126
|
+
raise Errors::BadRequest.new(
|
|
127
|
+
"parameter #{name.inspect} is an array of " \
|
|
128
|
+
"#{element.is_a?(::Hash) ? 'objects' : 'arrays'}, which a query cannot carry",
|
|
129
|
+
hint: DEPTH_HINT
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# An array leaf is the scalar-leaves rule; a hash leaf is the depth limit.
|
|
135
|
+
def reject_nonscalar_leaves!(name, value)
|
|
136
|
+
value.each do |key, leaf|
|
|
137
|
+
next if leaf.nil? || leaf.is_a?(::String)
|
|
138
|
+
|
|
139
|
+
raise nonscalar_leaf(name, key, leaf)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def nonscalar_leaf(name, key, leaf)
|
|
144
|
+
if leaf.is_a?(::Array)
|
|
145
|
+
return Errors::BadRequest.new(
|
|
146
|
+
"parameter #{name.inspect} has an array-valued leaf at #{name}[#{key}]",
|
|
147
|
+
hint: NARROWING_HINT
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Errors::BadRequest.new(
|
|
152
|
+
"parameter #{name.inspect} nests two levels deep at #{name}[#{key}]",
|
|
153
|
+
hint: DEPTH_HINT
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Coerces one decoded value to the type its property declares.
|
|
159
|
+
module Coercion
|
|
160
|
+
module_function
|
|
161
|
+
|
|
162
|
+
SCALARS = { 'integer' => :to_integer, 'number' => :to_number, 'boolean' => :to_boolean }.freeze
|
|
163
|
+
|
|
164
|
+
def coerce(value, property, path)
|
|
165
|
+
return value if value.nil?
|
|
166
|
+
|
|
167
|
+
type = ArgumentDecoder.declared_type(property)
|
|
168
|
+
return public_send(SCALARS.fetch(type), value, path) if SCALARS.key?(type)
|
|
169
|
+
|
|
170
|
+
case type
|
|
171
|
+
when 'string' then to_string(value, property, path)
|
|
172
|
+
when 'array' then to_array(value, property, path)
|
|
173
|
+
when 'object' then to_object(value, property, path)
|
|
174
|
+
else value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def to_integer(value, path)
|
|
179
|
+
scalar!(value, 'an integer', path)
|
|
180
|
+
Integer(value, 10)
|
|
181
|
+
rescue ::ArgumentError, ::TypeError
|
|
182
|
+
refuse(path, value, 'an integer', 'a JSON integer literal, e.g. 4')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def to_number(value, path)
|
|
186
|
+
scalar!(value, 'a number', path)
|
|
187
|
+
Float(value)
|
|
188
|
+
rescue ::ArgumentError, ::TypeError
|
|
189
|
+
refuse(path, value, 'a number', 'a JSON number literal, e.g. 4 or 4.5')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Only the literals `true` and `false`.
|
|
193
|
+
def to_boolean(value, path)
|
|
194
|
+
scalar!(value, 'a boolean', path)
|
|
195
|
+
return true if value == 'true'
|
|
196
|
+
return false if value == 'false'
|
|
197
|
+
|
|
198
|
+
refuse(path, value, 'a boolean', 'the literal true or false')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A string stays a String; a declared `format` is checked here.
|
|
202
|
+
def to_string(value, property, path)
|
|
203
|
+
scalar!(value, 'a string', path)
|
|
204
|
+
case ArgumentDecoder.fetch(property, :format).to_s
|
|
205
|
+
when 'date' then check_date!(value, path)
|
|
206
|
+
when 'date-time' then check_date_time!(value, path)
|
|
207
|
+
end
|
|
208
|
+
value
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def check_date!(value, path)
|
|
212
|
+
parsed = begin
|
|
213
|
+
::Date.strptime(value, '%Y-%m-%d')
|
|
214
|
+
rescue ::ArgumentError, ::TypeError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
return if parsed && parsed.strftime('%Y-%m-%d') == value
|
|
218
|
+
|
|
219
|
+
refuse(path, value, 'a date', FORMAT_SPELLINGS['date'])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def check_date_time!(value, path)
|
|
223
|
+
::Time.iso8601(value)
|
|
224
|
+
rescue ::ArgumentError, ::TypeError
|
|
225
|
+
refuse(path, value, 'a timestamp', FORMAT_SPELLINGS['date-time'])
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def to_array(value, property, path)
|
|
229
|
+
refuse(path, value, 'an array', "repeated #{path}%5B%5D=… parameters") if value.is_a?(::Hash)
|
|
230
|
+
|
|
231
|
+
items = ArgumentDecoder.fetch(property, :items)
|
|
232
|
+
Array(value).each_with_index.map { |element, i| coerce(element, items, "#{path}[#{i}]") }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def to_object(value, property, path)
|
|
236
|
+
refuse(path, value, 'an object', "one #{path}%5Bkey%5D=… parameter per key") unless value.is_a?(::Hash)
|
|
237
|
+
|
|
238
|
+
properties = ArgumentDecoder.fetch(property, :properties)
|
|
239
|
+
value.each_with_object({}) do |(key, leaf), out|
|
|
240
|
+
out[key.to_sym] = coerce(leaf, ArgumentDecoder.fetch(properties, key), "#{path}[#{key}]")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def scalar!(value, expected, path)
|
|
245
|
+
return unless value.is_a?(::Array) || value.is_a?(::Hash)
|
|
246
|
+
|
|
247
|
+
refuse(path, value, expected, "a single #{path}=… parameter")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def refuse(path, value, expected, spelling)
|
|
251
|
+
raise Errors::BadRequest.new(
|
|
252
|
+
"parameter #{path.inspect} is not #{expected}: #{value.inspect}",
|
|
253
|
+
hint: "#{path} is declared #{expected} — send #{spelling}. " \
|
|
254
|
+
"GET <endpoint>/schema publishes this verb's input_schema."
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# Hands one {ActionEvent} per action invocation, success or failure, to the
|
|
6
|
+
# operator's `c.audit_sink` callable; with no sink nothing is built or stored.
|
|
7
|
+
# Queries, `pay` and refusals before an action ran are not emitted.
|
|
8
|
+
#
|
|
9
|
+
# Kiosk.configure do |c|
|
|
10
|
+
# c.audit_sink = ->(event) { AuditLog.create!(**event.to_h) }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Called after the action's transaction has closed; a sink that raises is
|
|
14
|
+
# logged and never fails the action.
|
|
15
|
+
module AuditSink
|
|
16
|
+
class << self
|
|
17
|
+
def configured? = !Kiosk.configuration.audit_sink.nil?
|
|
18
|
+
|
|
19
|
+
def emit(event, sink: Kiosk.configuration.audit_sink)
|
|
20
|
+
return false if sink.nil?
|
|
21
|
+
|
|
22
|
+
sink.call(event)
|
|
23
|
+
true
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
report(event, e)
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def report(event, error)
|
|
32
|
+
message = '[kiosk-server] audit_sink raised for action ' \
|
|
33
|
+
"#{event.action.inspect}: #{error.class}: #{error.message}"
|
|
34
|
+
logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
|
|
35
|
+
logger ? logger.error(message) : warn(message)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Kiosk
|
|
6
|
+
module Server
|
|
7
|
+
# Server side of the proof-of-possession challenge: a single-use, short-lived
|
|
8
|
+
# nonce per public key.
|
|
9
|
+
module AuthChallenge
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The agent signs `challenge` into its JWS; `exp` is the Unix expiry.
|
|
13
|
+
def issue(public_key_pem:, now: Time.current)
|
|
14
|
+
config = Kiosk.configuration
|
|
15
|
+
nonce = SecureRandom.urlsafe_base64(32)
|
|
16
|
+
exp = now.to_i + config.auth_challenge_ttl
|
|
17
|
+
config.auth_challenge_store.put(public_key_pem.to_s.strip, nonce, exp)
|
|
18
|
+
{ challenge: nonce, exp: exp }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def consume!(public_key_pem:, nonce:)
|
|
22
|
+
ok = Kiosk.configuration.auth_challenge_store.take(public_key_pem.to_s.strip, nonce.to_s)
|
|
23
|
+
return true if ok
|
|
24
|
+
|
|
25
|
+
raise Errors::Unauthenticated.new(
|
|
26
|
+
'no matching auth challenge',
|
|
27
|
+
hint: 'GET /auth/challenge?public_key=… first, then sign the returned nonce; ' \
|
|
28
|
+
'challenges are single-use and short-lived'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|